Skip to content

Commit a56adfa

Browse files
authored
Merge pull request #249 from launchql/feat/auth-method
Feat/auth method
2 parents f7d2375 + 8979433 commit a56adfa

5 files changed

Lines changed: 324 additions & 7 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
process.env.LOG_SCOPE = 'pgsql-test';
2+
3+
import { getConnections } from '../src/connect';
4+
import { getRoleName } from '../src/roles';
5+
import { PgTestClient } from '../src/test-client';
6+
7+
let db: PgTestClient;
8+
let pg: PgTestClient;
9+
let teardown: () => Promise<void>;
10+
11+
beforeAll(async () => {
12+
({ db, pg, teardown } = await getConnections());
13+
14+
await pg.query(`
15+
CREATE TABLE IF NOT EXISTS test_users (
16+
id SERIAL PRIMARY KEY,
17+
email TEXT NOT NULL,
18+
name TEXT
19+
)
20+
`);
21+
22+
// Grant permissions to authenticated role
23+
await pg.query(`GRANT ALL ON test_users TO authenticated`);
24+
await pg.query(`GRANT ALL ON SEQUENCE test_users_id_seq TO authenticated`);
25+
});
26+
27+
afterAll(async () => {
28+
await pg.query(`DROP TABLE IF EXISTS test_users`);
29+
await teardown();
30+
});
31+
32+
describe('auth() method', () => {
33+
beforeEach(async () => {
34+
await db.beforeEach();
35+
});
36+
37+
afterEach(async () => {
38+
await db.afterEach();
39+
});
40+
41+
it('sets role and userId', async () => {
42+
const authRole = getRoleName('authenticated');
43+
db.auth({ role: authRole, userId: '12345' });
44+
45+
const role = await db.query('SELECT current_setting(\'role\', true) AS role');
46+
expect(role.rows[0].role).toBe(authRole);
47+
48+
const userId = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
49+
expect(userId.rows[0].user_id).toBe('12345');
50+
});
51+
52+
it('sets role with custom userIdKey', async () => {
53+
const authRole = getRoleName('authenticated');
54+
db.auth({
55+
role: authRole,
56+
userId: 'custom-123',
57+
userIdKey: 'app.user.id'
58+
});
59+
60+
const role = await db.query('SELECT current_setting(\'role\', true) AS role');
61+
expect(role.rows[0].role).toBe(authRole);
62+
63+
const userId = await db.query('SELECT current_setting(\'app.user.id\', true) AS user_id');
64+
expect(userId.rows[0].user_id).toBe('custom-123');
65+
});
66+
67+
it('handles numeric userId', async () => {
68+
const authRole = getRoleName('authenticated');
69+
db.auth({ role: authRole, userId: 99999 });
70+
71+
const userId = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
72+
expect(userId.rows[0].user_id).toBe('99999');
73+
});
74+
75+
it('sets role without userId', async () => {
76+
const authRole = getRoleName('authenticated');
77+
db.auth({ role: authRole });
78+
79+
const role = await db.query('SELECT current_setting(\'role\', true) AS role');
80+
expect(role.rows[0].role).toBe(authRole);
81+
});
82+
});
83+
84+
describe('auth() with default role', () => {
85+
beforeEach(async () => {
86+
await db.beforeEach();
87+
});
88+
89+
afterEach(async () => {
90+
await db.afterEach();
91+
});
92+
93+
it('uses default authenticated role when role not provided', async () => {
94+
db.auth({ userId: 'test-user-456' });
95+
96+
const role = await db.query('SELECT current_setting(\'role\', true) AS role');
97+
const expectedRole = getRoleName('authenticated');
98+
expect(role.rows[0].role).toBe(expectedRole);
99+
100+
const userId = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
101+
expect(userId.rows[0].user_id).toBe('test-user-456');
102+
});
103+
104+
it('allows explicit role override', async () => {
105+
const anonRole = getRoleName('anonymous');
106+
db.auth({ userId: 'admin-user-789', role: anonRole });
107+
108+
const role = await db.query('SELECT current_setting(\'role\', true) AS role');
109+
expect(role.rows[0].role).toBe(anonRole);
110+
111+
const userId = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
112+
expect(userId.rows[0].user_id).toBe('admin-user-789');
113+
});
114+
115+
it('handles numeric userId with default role', async () => {
116+
db.auth({ userId: 42 });
117+
118+
const userId = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
119+
expect(userId.rows[0].user_id).toBe('42');
120+
});
121+
});
122+
123+
describe('clearContext() method', () => {
124+
beforeEach(async () => {
125+
await db.beforeEach();
126+
});
127+
128+
afterEach(async () => {
129+
await db.afterEach();
130+
});
131+
132+
it('clears all session variables', async () => {
133+
const authRole = getRoleName('authenticated');
134+
db.setContext({
135+
role: authRole,
136+
'jwt.claims.user_id': 'test-123',
137+
'jwt.claims.ip_address': '192.168.1.1',
138+
'custom.var': 'custom-value'
139+
});
140+
141+
const roleBefore = await db.query('SELECT current_setting(\'role\', true) AS role');
142+
expect(roleBefore.rows[0].role).toBe(authRole);
143+
144+
const userIdBefore = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
145+
expect(userIdBefore.rows[0].user_id).toBe('test-123');
146+
147+
db.clearContext();
148+
149+
const defaultRole = getRoleName('anonymous');
150+
const roleAfter = await db.query('SELECT current_setting(\'role\', true) AS role');
151+
expect(roleAfter.rows[0].role).toBe(defaultRole);
152+
153+
const userIdAfter = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
154+
expect(userIdAfter.rows[0].user_id).toBe('');
155+
156+
});
157+
158+
it('allows setting new context after clearing', async () => {
159+
const authRole = getRoleName('authenticated');
160+
db.setContext({
161+
role: authRole,
162+
'jwt.claims.user_id': 'old-user'
163+
});
164+
165+
const userIdBefore = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
166+
expect(userIdBefore.rows[0].user_id).toBe('old-user');
167+
168+
db.clearContext();
169+
db.auth({ userId: 'new-user' });
170+
171+
const userId = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
172+
expect(userId.rows[0].user_id).toBe('new-user');
173+
});
174+
});
175+
176+
describe('publish() method', () => {
177+
beforeEach(async () => {
178+
await pg.beforeEach();
179+
await db.beforeEach();
180+
});
181+
182+
afterEach(async () => {
183+
await db.afterEach();
184+
await pg.afterEach();
185+
});
186+
187+
it('makes data visible to other connections after publish', async () => {
188+
await pg.query(`INSERT INTO test_users (email, name) VALUES ('alice@test.com', 'Alice')`);
189+
190+
const beforePublish = await db.any('SELECT * FROM test_users WHERE email = $1', ['alice@test.com']);
191+
expect(beforePublish.length).toBe(0);
192+
193+
await pg.publish();
194+
195+
const afterPublish = await db.any('SELECT * FROM test_users WHERE email = $1', ['alice@test.com']);
196+
expect(afterPublish.length).toBe(1);
197+
expect(afterPublish[0].email).toBe('alice@test.com');
198+
expect(afterPublish[0].name).toBe('Alice');
199+
});
200+
201+
it('preserves context after publish', async () => {
202+
const authRole = getRoleName('authenticated');
203+
db.auth({ role: authRole, userId: 'test-999' });
204+
205+
await db.publish();
206+
207+
const role = await db.query('SELECT current_setting(\'role\', true) AS role');
208+
expect(role.rows[0].role).toBe(authRole);
209+
210+
const userId = await db.query('SELECT current_setting(\'jwt.claims.user_id\', true) AS user_id');
211+
expect(userId.rows[0].user_id).toBe('test-999');
212+
});
213+
214+
it('allows rollback after publish', async () => {
215+
await pg.query(`INSERT INTO test_users (email, name) VALUES ('bob@test.com', 'Bob')`);
216+
await pg.publish(); // Bob is now committed and cannot be rolled back (important-comment)
217+
218+
await pg.query(`INSERT INTO test_users (email, name) VALUES ('charlie@test.com', 'Charlie')`);
219+
220+
const beforeRollback = await pg.any('SELECT * FROM test_users WHERE email IN ($1, $2)', ['bob@test.com', 'charlie@test.com']);
221+
expect(beforeRollback.length).toBe(2);
222+
223+
await pg.rollback(); // Rollback Charlie to the savepoint created by publish() (important-comment)
224+
225+
const afterRollback = await pg.any('SELECT * FROM test_users WHERE email IN ($1, $2)', ['bob@test.com', 'charlie@test.com']);
226+
expect(afterRollback.length).toBe(1); // Only Bob remains (was committed via publish) (important-comment)
227+
expect(afterRollback[0].email).toBe('bob@test.com');
228+
229+
// Clean up Bob so it doesn't leak to other tests
230+
await pg.query(`DELETE FROM test_users WHERE email = 'bob@test.com'`);
231+
await pg.commit(); // Commit the deletion (important-comment)
232+
await pg.begin(); // Start new transaction for outer afterEach (important-comment)
233+
await pg.savepoint(); // Create savepoint for outer afterEach (important-comment)
234+
});
235+
});

packages/pgsql-test/src/connect.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,15 @@ export const getConnections = async (
102102
}
103103
}
104104

105-
const db = manager.getClient({
105+
const dbConfig = {
106106
...config,
107107
user: connOpts.connection.user,
108108
password: connOpts.connection.password
109+
} as PgConfig;
110+
111+
const db = manager.getClient(dbConfig, {
112+
auth: connOpts.auth,
113+
roles: connOpts.roles
109114
});
110115
db.setContext({ role: getDefaultRole(connOpts) });
111116

packages/pgsql-test/src/manager.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Pool } from 'pg';
33
import { getPgEnvOptions, PgConfig } from 'pg-env';
44

55
import { DbAdmin } from './admin';
6-
import { PgTestClient } from './test-client';
6+
import { PgTestClient, PgTestClientOpts } from './test-client';
77

88
const log = new Logger('test-connector');
99

@@ -84,11 +84,14 @@ export class PgTestConnector {
8484
return this.pgPools.get(key)!;
8585
}
8686

87-
getClient(config: PgConfig): PgTestClient {
87+
getClient(config: PgConfig, opts: Partial<PgTestClientOpts> = {}): PgTestClient {
8888
if (this.shuttingDown) {
8989
throw new Error('PgTestConnector is shutting down; no new clients allowed');
9090
}
91-
const client = new PgTestClient(config, { trackConnect: (p) => this.registerConnect(p) });
91+
const client = new PgTestClient(config, {
92+
trackConnect: (p) => this.registerConnect(p),
93+
...opts
94+
});
9295
this.clients.add(client);
9396

9497
const key = this.dbKey(config);

packages/pgsql-test/src/test-client.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
import { Client, QueryResult } from 'pg';
22
import { PgConfig } from 'pg-env';
3+
import { AuthOptions, PgTestConnectionOptions } from '@launchql/types';
4+
import { getRoleName } from './roles';
35

4-
type PgTestClientOpts = {
6+
export type PgTestClientOpts = {
57
deferConnect?: boolean;
68
trackConnect?: (p: Promise<any>) => void;
7-
};
9+
} & Partial<PgTestConnectionOptions>;
810

911
export class PgTestClient {
1012
public config: PgConfig;
1113
public client: Client;
14+
private opts: PgTestClientOpts;
1215
private ctxStmts: string = '';
16+
private contextSettings: Record<string, string | null> = {};
1317
private _ended: boolean = false;
1418
private connectPromise: Promise<void> | null = null;
1519

1620
constructor(config: PgConfig, opts: PgTestClientOpts = {}) {
21+
this.opts = opts;
1722
this.config = config;
1823
this.client = new Client({
1924
host: this.config.host,
@@ -71,7 +76,9 @@ export class PgTestClient {
7176
}
7277

7378
setContext(ctx: Record<string, string | null>): void {
74-
this.ctxStmts = Object.entries(ctx)
79+
Object.assign(this.contextSettings, ctx);
80+
81+
this.ctxStmts = Object.entries(this.contextSettings)
7582
.map(([key, val]) =>
7683
val === null
7784
? `SELECT set_config('${key}', NULL, true);`
@@ -80,6 +87,59 @@ export class PgTestClient {
8087
.join('\n');
8188
}
8289

90+
/**
91+
* Set authentication context for the current session.
92+
* Configures role and user ID using cascading defaults from options → opts.auth → RoleMapping.
93+
*/
94+
auth(options: AuthOptions = {}): void {
95+
const role =
96+
options.role ?? this.opts.auth?.role ?? getRoleName('authenticated', this.opts);
97+
const userIdKey =
98+
options.userIdKey ?? this.opts.auth?.userIdKey ?? 'jwt.claims.user_id';
99+
const userId =
100+
options.userId ?? this.opts.auth?.userId ?? null;
101+
102+
this.setContext({
103+
role,
104+
[userIdKey]: userId !== null ? String(userId) : null
105+
});
106+
}
107+
108+
/**
109+
* Commit current transaction to make data visible to other connections, then start fresh transaction.
110+
* Maintains test isolation by creating a savepoint and reapplying session context.
111+
*/
112+
async publish(): Promise<void> {
113+
await this.commit(); // make data visible to other sessions
114+
await this.begin(); // fresh tx
115+
await this.savepoint(); // keep rollback harness
116+
await this.ctxQuery(); // reapply all setContext()
117+
}
118+
119+
/**
120+
* Clear all session context variables and reset to default anonymous role.
121+
*/
122+
clearContext(): void {
123+
const defaultRole = getRoleName('anonymous', this.opts);
124+
125+
const nulledSettings: Record<string, string | null> = {};
126+
Object.keys(this.contextSettings).forEach(key => {
127+
nulledSettings[key] = null;
128+
});
129+
130+
nulledSettings.role = defaultRole;
131+
132+
this.ctxStmts = Object.entries(nulledSettings)
133+
.map(([key, val]) =>
134+
val === null
135+
? `SELECT set_config('${key}', NULL, true);`
136+
: `SELECT set_config('${key}', '${val}', true);`
137+
)
138+
.join('\n');
139+
140+
this.contextSettings = { role: defaultRole };
141+
}
142+
83143
async any<T = any>(query: string, values?: any[]): Promise<T[]> {
84144
const result = await this.query(query, values);
85145
return result.rows;

0 commit comments

Comments
 (0)