Skip to content

Commit c46c037

Browse files
committed
feat: add public access security tests for CRUD endpoints
1 parent 43c0adb commit c46c037

1 file changed

Lines changed: 129 additions & 0 deletions

File tree

backend/test/ava-tests/non-saas-tests/non-saas-table-pure-crud-operations-e2e.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,3 +545,132 @@ test.serial(`${currentTest} GET public-permissions returns the configured tables
545545
t.truthy(tableEntry);
546546
t.deepEqual(tableEntry.readableColumns, [testTableColumnName]);
547547
});
548+
549+
currentTest = 'Public access security invariants';
550+
551+
test.serial(
552+
`${currentTest} EVERY CRUD endpoint denies an unauthenticated user when no public policy is configured`,
553+
async (t) => {
554+
const { connectionId, testTableName, testTableColumnName, testTableSecondColumnName } =
555+
await createConnectionAndTable();
556+
557+
const row = JSON.stringify({
558+
[testTableColumnName]: faker.person.firstName(),
559+
[testTableSecondColumnName]: faker.internet.email(),
560+
});
561+
562+
// getRows
563+
const getRows = await request(app.getHttpServer())
564+
.post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=100`)
565+
.send({})
566+
.set('Content-Type', 'application/json')
567+
.set('Accept', 'application/json');
568+
t.is(getRows.status, 403);
569+
t.is(Object.hasOwn(JSON.parse(getRows.text), 'rows'), false);
570+
571+
// readRow by primary key
572+
const readRow = await request(app.getHttpServer())
573+
.get(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`)
574+
.set('Content-Type', 'application/json')
575+
.set('Accept', 'application/json');
576+
t.is(readRow.status, 403);
577+
t.is(Object.hasOwn(JSON.parse(readRow.text), 'row'), false);
578+
579+
// createRow
580+
const createRow = await request(app.getHttpServer())
581+
.post(`/table/crud/${connectionId}?tableName=${testTableName}`)
582+
.send(row)
583+
.set('Content-Type', 'application/json')
584+
.set('Accept', 'application/json');
585+
t.is(createRow.status, 403);
586+
587+
// updateRow
588+
const updateRow = await request(app.getHttpServer())
589+
.put(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`)
590+
.send(row)
591+
.set('Content-Type', 'application/json')
592+
.set('Accept', 'application/json');
593+
t.is(updateRow.status, 403);
594+
595+
// deleteRow
596+
const deleteRow = await request(app.getHttpServer())
597+
.delete(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`)
598+
.set('Content-Type', 'application/json')
599+
.set('Accept', 'application/json');
600+
t.is(deleteRow.status, 403);
601+
},
602+
);
603+
604+
test.serial(`${currentTest} a public policy scoped to one table does NOT grant access to other tables`, async (t) => {
605+
const { token, connectionId, testTableName } = await createConnectionAndTable();
606+
607+
// A second table living in the same database (same connection).
608+
const connectionToTestDB = getTestData(mockFactory).connectionToMySQL;
609+
const secondTable = await createTestTable(connectionToTestDB);
610+
testTables.push(secondTable.testTableName);
611+
612+
// Grant public access to the FIRST table only.
613+
const setRes = await setPublicPermissions(connectionId, token, [{ tableName: testTableName }]);
614+
t.is(setRes.status, 200);
615+
616+
// The granted table is reachable...
617+
const allowed = await request(app.getHttpServer())
618+
.post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=10`)
619+
.send({})
620+
.set('Content-Type', 'application/json')
621+
.set('Accept', 'application/json');
622+
t.is(allowed.status, 200);
623+
624+
// ...but the OTHER table must not be: no row data may leak.
625+
const deniedRows = await request(app.getHttpServer())
626+
.post(`/table/crud/rows/${connectionId}?tableName=${secondTable.testTableName}&page=1&perPage=10`)
627+
.send({})
628+
.set('Content-Type', 'application/json')
629+
.set('Accept', 'application/json');
630+
t.is(deniedRows.status, 403);
631+
t.is(Object.hasOwn(JSON.parse(deniedRows.text), 'rows'), false);
632+
633+
const deniedRead = await request(app.getHttpServer())
634+
.get(`/table/crud/${connectionId}?tableName=${secondTable.testTableName}&id=1`)
635+
.set('Content-Type', 'application/json')
636+
.set('Accept', 'application/json');
637+
t.is(deniedRead.status, 403);
638+
});
639+
640+
test.serial(`${currentTest} readRow strips non-readable columns for an unauthenticated user`, async (t) => {
641+
const { token, connectionId, testTableName, testTableColumnName, testTableSecondColumnName } =
642+
await createConnectionAndTable();
643+
644+
await setPublicPermissions(connectionId, token, [
645+
{ tableName: testTableName, readableColumns: [testTableColumnName] },
646+
]);
647+
648+
const res = await request(app.getHttpServer())
649+
.get(`/table/crud/${connectionId}?tableName=${testTableName}&id=1`)
650+
.set('Content-Type', 'application/json')
651+
.set('Accept', 'application/json');
652+
653+
t.is(res.status, 200);
654+
const ro = JSON.parse(res.text);
655+
t.is(Object.hasOwn(ro.row, testTableColumnName), true);
656+
t.is(Object.hasOwn(ro.row, testTableSecondColumnName), false);
657+
t.is(Object.hasOwn(ro.row, 'id'), false);
658+
});
659+
660+
test.serial(`${currentTest} an invalid/expired JWT cookie is rejected, never treated as public`, async (t) => {
661+
const { connectionId, token, testTableName } = await createConnectionAndTable();
662+
// Enable public access so that, if the bad token were silently ignored, the request would succeed.
663+
await setPublicPermissions(connectionId, token, [{ tableName: testTableName }]);
664+
665+
const res = await request(app.getHttpServer())
666+
.post(`/table/crud/rows/${connectionId}?tableName=${testTableName}&page=1&perPage=10`)
667+
.send({})
668+
.set('Cookie', ['rocketadmin_cookie=this.is.not.a.valid.jwt'])
669+
.set('Content-Type', 'application/json')
670+
.set('Accept', 'application/json');
671+
672+
// A malformed credential must be rejected, never silently downgraded to public access.
673+
// (The existing middleware surfaces this as a 4xx/5xx; the invariant is "not 200, no data leaked".)
674+
t.not(res.status, 200);
675+
t.is(Object.hasOwn(JSON.parse(res.text), 'rows'), false);
676+
});

0 commit comments

Comments
 (0)