Skip to content

Commit c6cd8b4

Browse files
srelonclaude
andcommitted
fix: reject backendOnly on discovered primary key AdminForth/1934/list-rows-lose-their-primary-k
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2c7800d commit c6cd8b4

2 files changed

Lines changed: 76 additions & 25 deletions

File tree

‎adminforth/modules/configValidator.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -527,12 +527,6 @@ export default class ConfigValidator implements IConfigValidator {
527527
errors.push(`Resource "${res.resourceId}" has duplicate column name "${col.name}"`);
528528
}
529529

530-
// frontend builds record links from primary key values, so they must always reach it.
531-
// a backendOnly function is rejected too: it can hide the key only for some users
532-
if (col.primaryKey && col.backendOnly) {
533-
errors.push(`Resource "${res.resourceId}" column "${col.name}" is a primary key and cannot be backendOnly`);
534-
}
535-
536530
col.label = col.label || guessLabelFromName(col.name);
537531
//define default sortable
538532
if (!Object.keys(col).includes('sortable')) { col.sortable = !col.virtual; }
@@ -1358,6 +1352,13 @@ export default class ConfigValidator implements IConfigValidator {
13581352

13591353
postProcessAfterDiscover(resource: AdminForthResource) {
13601354
resource.columns.forEach((column) => {
1355+
// checked after discovery, because primaryKey may come from the database schema.
1356+
// frontend builds record links from primary key values, so they must always reach it.
1357+
// a backendOnly function is rejected too: it can hide the key only for some users
1358+
if (column.primaryKey && column.backendOnly) {
1359+
throw new Error(`Resource "${resource.resourceId}" column "${column.name}" is a primary key and cannot be backendOnly`);
1360+
}
1361+
13611362
// if db/user says column is required in boolean, expand
13621363
if (typeof column.required === 'boolean') {
13631364
column.required = { create: column.required, edit: column.required };
Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,104 @@
1+
import { mkdtempSync, rmSync } from 'node:fs';
12
import { createRequire } from 'node:module';
3+
import os from 'node:os';
24
import path from 'node:path';
35
import { fileURLToPath, pathToFileURL } from 'node:url';
46

57
const currentDir = path.dirname(fileURLToPath(import.meta.url));
68
const applicationDir = path.resolve(currentDir, '../application');
79

810
// resolves to the BUILT package (adminforth/dist) the test application runs on;
9-
// rebuild it (`npx tsc` in adminforth/) after editing configValidator or the assertions below test stale code
11+
// rebuild it (`npx tsc` in adminforth/) after editing adminforth sources or the assertions below test stale code
1012
const require = createRequire(import.meta.url);
13+
// jest's resolver does not know the `node:sqlite` builtin, so it is required directly
14+
const { DatabaseSync } = require('node:sqlite');
1115
const adminforthEntry = require.resolve('adminforth', { paths: [applicationDir] });
1216
const { default: AdminForth } = await import(pathToFileURL(adminforthEntry).href);
1317

14-
function appWithPkColumn(pkColumn: Record<string, unknown>) {
18+
// own fixture, so the primary key is discovered from a known schema
19+
const fixtureDir = mkdtempSync(path.join(os.tmpdir(), 'af-pk-backend-only-'));
20+
const dbPath = path.join(fixtureDir, 'db.sqlite');
21+
const db = new DatabaseSync(dbPath);
22+
db.exec(`
23+
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL, password_hash TEXT NOT NULL);
24+
INSERT INTO users VALUES (1, 'pk@example.test', 'hidden');
25+
`);
26+
db.close();
27+
28+
process.env.ADMINFORTH_SECRET ??= 'x'.repeat(64);
29+
30+
afterAll(() => {
31+
rmSync(fixtureDir, { recursive: true, force: true });
32+
});
33+
34+
// constructor validation and database discovery together, as on real startup
35+
async function startApp(idColumn: Record<string, unknown>) {
1536
// AdminForth allows one instance per process; each test needs a fresh one
1637
delete (globalThis as any).adminforth;
17-
return new AdminForth({
38+
const app = new AdminForth({
1839
baseUrl: '',
1940
auth: {
20-
usersResourceId: 'adminuser',
41+
usersResourceId: 'users',
2142
usernameField: 'email',
2243
passwordHashField: 'password_hash',
2344
},
24-
dataSources: [{ id: 'sqlite', url: `sqlite://${path.join(applicationDir, '.db.sqlite')}` }],
45+
dataSources: [{ id: 'sqlite', url: `sqlite://${dbPath}` }],
2546
resources: [{
26-
resourceId: 'adminuser',
27-
table: 'adminuser',
47+
resourceId: 'users',
48+
table: 'users',
2849
dataSource: 'sqlite',
2950
columns: [
30-
{ name: 'id', primaryKey: true, ...pkColumn },
31-
{ name: 'email', required: true },
51+
{ name: 'id', ...idColumn },
52+
{ name: 'email' },
3253
{ name: 'password_hash', backendOnly: true },
3354
],
3455
}],
35-
menu: [{ label: 'Users', resourceId: 'adminuser' }],
56+
menu: [{ label: 'Users', resourceId: 'users' }],
57+
});
58+
await app.discoverDatabases();
59+
return app;
60+
}
61+
62+
async function listUsers(app: any) {
63+
const endpoints: any[] = [];
64+
app.restApi.registerEndpoints({ endpoint: (config: any) => endpoints.push(config) });
65+
const { handler } = endpoints.find((endpoint) => endpoint.path === '/get_resource_data');
66+
return handler({
67+
body: { resourceId: 'users', source: 'list', limit: 10, offset: 0, filters: [], sort: [] },
68+
adminUser: { pk: '1', username: 'pk@example.test', dbUser: {} },
69+
headers: {},
70+
query: {},
71+
cookies: {},
72+
requestUrl: '/get_resource_data',
73+
abortSignal: new AbortController().signal,
3674
});
3775
}
3876

3977
const pkBackendOnlyError = /column "id" is a primary key and cannot be backendOnly/;
4078

41-
describe('primary key column cannot be backendOnly', () => {
42-
it('rejects backendOnly: true on the primary key', () => {
43-
expect(() => appWithPkColumn({ backendOnly: true })).toThrow(pkBackendOnlyError);
79+
// `primaryKey` is omitted, not set to undefined: undefined would override the discovered value on merge
80+
describe.each([
81+
['declared', { primaryKey: true }],
82+
['discovered from database', {}],
83+
])('%s primary key', (_, pkFlag) => {
84+
it.each([
85+
['true', true],
86+
['function returning true', () => true],
87+
['function returning false', () => false],
88+
])('rejects startup with backendOnly %s', async (_, backendOnly) => {
89+
await expect(startApp({ ...pkFlag, backendOnly })).rejects.toThrow(pkBackendOnlyError);
4490
});
4591

46-
it('rejects backendOnly function on the primary key', () => {
47-
expect(() => appWithPkColumn({ backendOnly: () => false })).toThrow(pkBackendOnlyError);
48-
});
92+
it.each([
93+
['false', { backendOnly: false }],
94+
['omitted', {}],
95+
])('starts with backendOnly %s and keeps the id in list rows', async (_, backendOnlyFlag) => {
96+
const app = await startApp({ ...pkFlag, ...backendOnlyFlag });
97+
const result = await listUsers(app);
4998

50-
it('accepts backendOnly: false on the primary key and backendOnly on other columns', () => {
51-
expect(() => appWithPkColumn({ backendOnly: false })).not.toThrow();
52-
expect(() => appWithPkColumn({})).not.toThrow();
99+
expect(result.data).toEqual([expect.objectContaining({ id: 1, email: 'pk@example.test' })]);
100+
expect(result.recordIds).toEqual([1]);
101+
// non-primary-key backendOnly column stays allowed and hidden
102+
expect(result.data[0]).not.toHaveProperty('password_hash');
53103
});
54104
});

0 commit comments

Comments
 (0)