@zeno-lib/db 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clients-BlUEH7QJ.d.mts +58 -0
- package/dist/clients-CdPr3mCZ.mjs +197 -0
- package/dist/errors.d.mts +70 -0
- package/dist/errors.mjs +78 -0
- package/dist/function-names-BDc-AuFm.mjs +10 -0
- package/dist/function-names-hv2mj_x1.d.mts +5 -0
- package/dist/index.d.mts +2 -42
- package/dist/index.mjs +2 -167
- package/dist/next.d.mts +87 -0
- package/dist/next.mjs +69 -0
- package/dist/query.d.mts +103 -0
- package/dist/query.mjs +115 -0
- package/dist/schema.d.mts +20 -11
- package/dist/schema.mjs +11 -12
- package/package.json +29 -5
- package/src/casing.test.ts +43 -20
- package/src/clients.ts +60 -5
- package/src/database-url.test.ts +88 -0
- package/src/define-action.ts +63 -0
- package/src/errors.test.ts +95 -0
- package/src/errors.ts +133 -0
- package/src/function-names.ts +21 -0
- package/src/index.ts +2 -0
- package/src/next.test-d.ts +32 -0
- package/src/next.test.ts +178 -0
- package/src/next.ts +112 -0
- package/src/query.test-d.ts +20 -0
- package/src/query.test.ts +261 -0
- package/src/query.ts +252 -0
- package/src/schema.test-d.ts +24 -0
- package/src/schema.ts +51 -21
package/src/next.test.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { AuthError, type JwtPayload } from "@supabase/supabase-js"
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
3
|
+
import { z } from "zod"
|
|
4
|
+
import { createDefineAction } from "./define-action.ts"
|
|
5
|
+
|
|
6
|
+
// No database: the RLS client factory is replaced by a stub that returns a
|
|
7
|
+
// marker object, so nothing here builds a pool or opens a connection. What
|
|
8
|
+
// that client does with the claims is covered by test/rls.integration.test.ts.
|
|
9
|
+
const { createSupabaseClientMock } = vi.hoisted(() => ({
|
|
10
|
+
createSupabaseClientMock: vi.fn((claims: unknown) => ({
|
|
11
|
+
claims,
|
|
12
|
+
fake: true,
|
|
13
|
+
})),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
vi.mock("./clients.ts", () => ({
|
|
17
|
+
createSupabaseClient: createSupabaseClientMock,
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
type ClaimsSource,
|
|
22
|
+
createRequestDb,
|
|
23
|
+
UnauthenticatedError,
|
|
24
|
+
} from "./next.ts"
|
|
25
|
+
|
|
26
|
+
const claims: JwtPayload = {
|
|
27
|
+
aal: "aal1",
|
|
28
|
+
aud: "authenticated",
|
|
29
|
+
exp: 0,
|
|
30
|
+
iat: 0,
|
|
31
|
+
iss: "test",
|
|
32
|
+
role: "authenticated",
|
|
33
|
+
roles: ["admin"],
|
|
34
|
+
session_id: "session",
|
|
35
|
+
sub: "11111111-1111-1111-1111-111111111111",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type GetClaimsResult = Awaited<ReturnType<ClaimsSource["auth"]["getClaims"]>>
|
|
39
|
+
|
|
40
|
+
function getClaimsResult(
|
|
41
|
+
payload: JwtPayload | null,
|
|
42
|
+
error: AuthError | null
|
|
43
|
+
): GetClaimsResult {
|
|
44
|
+
if (payload) {
|
|
45
|
+
return {
|
|
46
|
+
data: {
|
|
47
|
+
claims: payload,
|
|
48
|
+
header: { alg: "HS256", kid: "test", typ: "JWT" },
|
|
49
|
+
signature: new Uint8Array(),
|
|
50
|
+
},
|
|
51
|
+
error: null,
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return error ? { data: null, error } : { data: null, error: null }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function supabaseReturning(result: GetClaimsResult): () => ClaimsSource {
|
|
58
|
+
return () => ({ auth: { getClaims: () => Promise.resolve(result) } })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
createSupabaseClientMock.mockClear()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe("defineAction", () => {
|
|
66
|
+
const schema = z.object({ id: z.number().int().positive() }).strict()
|
|
67
|
+
|
|
68
|
+
it("parses the input, then calls the handler with db, parsed input and context", async () => {
|
|
69
|
+
const context = { claims: { sub: "user-1" }, db: { name: "db" } }
|
|
70
|
+
const getContext = vi.fn(() => Promise.resolve(context))
|
|
71
|
+
const handler = vi.fn((db: { name: string }, input: { id: number }) =>
|
|
72
|
+
Promise.resolve(`${db.name}:${input.id}`)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const action = createDefineAction(getContext)(schema, handler)
|
|
76
|
+
|
|
77
|
+
await expect(action({ id: 7 })).resolves.toBe("db:7")
|
|
78
|
+
expect(handler).toHaveBeenCalledWith(context.db, { id: 7 }, context)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("hands the handler the claims for authorship", async () => {
|
|
82
|
+
const context = { claims: { sub: "user-1" }, db: {} }
|
|
83
|
+
const action = createDefineAction(() => Promise.resolve(context))(
|
|
84
|
+
schema,
|
|
85
|
+
(_db, input, { claims: { sub } }) => ({ createdBy: sub, ...input })
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
await expect(action({ id: 1 })).resolves.toEqual({
|
|
89
|
+
createdBy: "user-1",
|
|
90
|
+
id: 1,
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("rejects invalid input before resolving the context or calling the handler", async () => {
|
|
95
|
+
const getContext = vi.fn(() => Promise.resolve({ db: {} }))
|
|
96
|
+
const handler = vi.fn()
|
|
97
|
+
const action = createDefineAction(getContext)(schema, handler)
|
|
98
|
+
|
|
99
|
+
await expect(action({ id: -1 })).rejects.toBeInstanceOf(z.ZodError)
|
|
100
|
+
expect(getContext).not.toHaveBeenCalled()
|
|
101
|
+
expect(handler).not.toHaveBeenCalled()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("propagates a context failure without calling the handler", async () => {
|
|
105
|
+
const handler = vi.fn()
|
|
106
|
+
const action = createDefineAction(() =>
|
|
107
|
+
Promise.reject(new UnauthenticatedError())
|
|
108
|
+
)(schema, handler)
|
|
109
|
+
|
|
110
|
+
await expect(action({ id: 1 })).rejects.toBeInstanceOf(UnauthenticatedError)
|
|
111
|
+
expect(handler).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe("createRequestDb", () => {
|
|
116
|
+
it("builds the RLS client from the whole verified claims object", async () => {
|
|
117
|
+
const { getRequestContext, getRequestDb } = createRequestDb({
|
|
118
|
+
connectionString: () => "postgresql://lazy",
|
|
119
|
+
supabase: supabaseReturning(getClaimsResult(claims, null)),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const context = await getRequestContext()
|
|
123
|
+
|
|
124
|
+
expect(context.claims).toBe(claims)
|
|
125
|
+
expect(createSupabaseClientMock).toHaveBeenCalledWith(claims, {
|
|
126
|
+
connectionString: "postgresql://lazy",
|
|
127
|
+
})
|
|
128
|
+
await expect(getRequestDb()).resolves.toEqual({ claims, fake: true })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("throws UnauthenticatedError when there is no session", async () => {
|
|
132
|
+
const { getRequestDb } = createRequestDb({
|
|
133
|
+
supabase: supabaseReturning(getClaimsResult(null, null)),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const error = await getRequestDb().catch((caught: unknown) => caught)
|
|
137
|
+
|
|
138
|
+
expect(error).toBeInstanceOf(UnauthenticatedError)
|
|
139
|
+
expect(error).toHaveProperty("name", "UnauthenticatedError")
|
|
140
|
+
expect(createSupabaseClientMock).not.toHaveBeenCalled()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("throws UnauthenticatedError for claims without a subject", async () => {
|
|
144
|
+
const { getRequestDb } = createRequestDb({
|
|
145
|
+
supabase: supabaseReturning(
|
|
146
|
+
getClaimsResult({ ...claims, sub: "" }, null)
|
|
147
|
+
),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
await expect(getRequestDb()).rejects.toBeInstanceOf(UnauthenticatedError)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("rethrows a getClaims error as is", async () => {
|
|
154
|
+
const failure = new AuthError("jwks unreachable")
|
|
155
|
+
const { getRequestDb } = createRequestDb({
|
|
156
|
+
supabase: supabaseReturning(getClaimsResult(null, failure)),
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
await expect(getRequestDb()).rejects.toBe(failure)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("binds defineAction to the request context", async () => {
|
|
163
|
+
const { defineAction } = createRequestDb({
|
|
164
|
+
supabase: supabaseReturning(getClaimsResult(claims, null)),
|
|
165
|
+
})
|
|
166
|
+
const action = defineAction(z.string(), (db, input, context) => ({
|
|
167
|
+
db,
|
|
168
|
+
input,
|
|
169
|
+
sub: context.claims.sub,
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
await expect(action("hello")).resolves.toEqual({
|
|
173
|
+
db: { claims, fake: true },
|
|
174
|
+
input: "hello",
|
|
175
|
+
sub: claims.sub,
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
})
|
package/src/next.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { JwtPayload, SupabaseClient } from "@supabase/supabase-js"
|
|
2
|
+
import type { AnyRelations, EmptyRelations } from "drizzle-orm"
|
|
3
|
+
import { cache } from "react"
|
|
4
|
+
import {
|
|
5
|
+
type CreateClientConfig,
|
|
6
|
+
createSupabaseClient,
|
|
7
|
+
type DrizzleClient,
|
|
8
|
+
} from "./clients.ts"
|
|
9
|
+
import { createDefineAction, type DefineAction } from "./define-action.ts"
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
ActionContext,
|
|
13
|
+
ActionHandler,
|
|
14
|
+
ActionSchema,
|
|
15
|
+
DefineAction,
|
|
16
|
+
} from "./define-action.ts"
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Thrown when a request carries no verified session. A named class so a caller
|
|
20
|
+
* with a legitimate answer for that case (a permission check, where "not signed
|
|
21
|
+
* in" means "no") can tell it apart from a real failure with `instanceof`.
|
|
22
|
+
*/
|
|
23
|
+
export class UnauthenticatedError extends Error {
|
|
24
|
+
constructor(message = "Unauthorized: no verified session on this request") {
|
|
25
|
+
super(message)
|
|
26
|
+
this.name = "UnauthenticatedError"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The only part of a Supabase client the request context reads. */
|
|
31
|
+
export type ClaimsSource = { auth: Pick<SupabaseClient["auth"], "getClaims"> }
|
|
32
|
+
|
|
33
|
+
/** A verified request: the full `getClaims()` payload and the RLS client. */
|
|
34
|
+
export type RequestContext<TRelations extends AnyRelations = EmptyRelations> = {
|
|
35
|
+
readonly claims: JwtPayload
|
|
36
|
+
readonly db: DrizzleClient<TRelations>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CreateRequestDbOptions<
|
|
40
|
+
TRelations extends AnyRelations = EmptyRelations,
|
|
41
|
+
> = Omit<CreateClientConfig<TRelations>, "connectionString"> & {
|
|
42
|
+
/**
|
|
43
|
+
* Builds the request's Supabase client, e.g. `createClient` from
|
|
44
|
+
* `@zeno-lib/supabase/next-server` (cookie session via `next/headers`).
|
|
45
|
+
*/
|
|
46
|
+
supabase: () => ClaimsSource | Promise<ClaimsSource>
|
|
47
|
+
/**
|
|
48
|
+
* Overrides `SUPABASE_DATABASE_URL`. A function is resolved per request, not
|
|
49
|
+
* at import, so a build without server secrets can still import the module.
|
|
50
|
+
*/
|
|
51
|
+
connectionString?: string | (() => string)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type RequestDb<TRelations extends AnyRelations = EmptyRelations> = {
|
|
55
|
+
/** Memoised per render: `{ claims, db }` for the verified caller. */
|
|
56
|
+
getRequestContext: () => Promise<RequestContext<TRelations>>
|
|
57
|
+
/** The common case: just the RLS-bound client. */
|
|
58
|
+
getRequestDb: () => Promise<DrizzleClient<TRelations>>
|
|
59
|
+
/** Wraps a handler into a `"use server"` export; see `DefineAction`. */
|
|
60
|
+
defineAction: DefineAction<RequestContext<TRelations>>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Request-scoped, RLS-bound Drizzle access for Next.js. Verifies the session
|
|
65
|
+
* with `getClaims()` (which checks the signature) and installs the **whole**
|
|
66
|
+
* claims object, so custom claims reach `auth.jwt()` in policies. There is no
|
|
67
|
+
* fallback to `anon`: a caller without a verified `sub` gets
|
|
68
|
+
* `UnauthenticatedError` rather than the empty result of a policy it never
|
|
69
|
+
* satisfied.
|
|
70
|
+
*
|
|
71
|
+
* `getRequestContext` is wrapped in React's `cache`, so one server render shares
|
|
72
|
+
* one verification and one handle. A server action invoked from the browser
|
|
73
|
+
* runs outside a render and resolves afresh on every call.
|
|
74
|
+
*
|
|
75
|
+
* Do not `close()` the handle: pools are shared and reference-counted.
|
|
76
|
+
*/
|
|
77
|
+
export function createRequestDb<
|
|
78
|
+
TRelations extends AnyRelations = EmptyRelations,
|
|
79
|
+
>(options: CreateRequestDbOptions<TRelations>): RequestDb<TRelations> {
|
|
80
|
+
const { connectionString, supabase, ...config } = options
|
|
81
|
+
|
|
82
|
+
const getRequestContext = cache(
|
|
83
|
+
async (): Promise<RequestContext<TRelations>> => {
|
|
84
|
+
const client = await supabase()
|
|
85
|
+
const { data, error } = await client.auth.getClaims()
|
|
86
|
+
|
|
87
|
+
if (error) {
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
if (!data?.claims.sub) {
|
|
91
|
+
throw new UnauthenticatedError()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const url =
|
|
95
|
+
typeof connectionString === "function"
|
|
96
|
+
? connectionString()
|
|
97
|
+
: connectionString
|
|
98
|
+
const db = createSupabaseClient<TRelations>(
|
|
99
|
+
data.claims,
|
|
100
|
+
url === undefined ? config : { ...config, connectionString: url }
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return { claims: data.claims, db }
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
defineAction: createDefineAction(getRequestContext),
|
|
109
|
+
getRequestContext,
|
|
110
|
+
getRequestDb: async () => (await getRequestContext()).db,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineRelations } from "drizzle-orm"
|
|
2
|
+
import type { PgAsyncDatabase, PgAsyncTransaction } from "drizzle-orm/pg-core"
|
|
3
|
+
import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"
|
|
4
|
+
import { expectTypeOf, test } from "vitest"
|
|
5
|
+
import type { DrizzleClient } from "./clients.ts"
|
|
6
|
+
import type { QueryExecutor } from "./query.ts"
|
|
7
|
+
import { primaryId, table } from "./schema.ts"
|
|
8
|
+
|
|
9
|
+
const posts = table("posts", { id: primaryId("sequential") })
|
|
10
|
+
const relations = defineRelations({ posts })
|
|
11
|
+
|
|
12
|
+
test("selectFunctionPermissions takes every client and transaction shape", () => {
|
|
13
|
+
expectTypeOf<DrizzleClient<typeof relations>>().toExtend<QueryExecutor>()
|
|
14
|
+
expectTypeOf<
|
|
15
|
+
PgAsyncDatabase<PostgresJsQueryResultHKT, typeof relations>
|
|
16
|
+
>().toExtend<QueryExecutor>()
|
|
17
|
+
expectTypeOf<
|
|
18
|
+
PgAsyncTransaction<PostgresJsQueryResultHKT, typeof relations>
|
|
19
|
+
>().toExtend<QueryExecutor>()
|
|
20
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { defineRelations, type SQL } from "drizzle-orm"
|
|
2
|
+
import { PgDialect, text } from "drizzle-orm/pg-core"
|
|
3
|
+
import { drizzle } from "drizzle-orm/postgres-js"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import {
|
|
6
|
+
definedValues,
|
|
7
|
+
excludedSet,
|
|
8
|
+
functionPermissionTables,
|
|
9
|
+
maybeOne,
|
|
10
|
+
one,
|
|
11
|
+
type QueryExecutor,
|
|
12
|
+
selectFunctionPermissions,
|
|
13
|
+
} from "./query.ts"
|
|
14
|
+
import { primaryId, schema, table, view } from "./schema.ts"
|
|
15
|
+
|
|
16
|
+
// No connection anywhere: `drizzle.mock()` builds statements for `.toSQL()`,
|
|
17
|
+
// and `PgDialect` renders a bare `SQL` the way the driver would receive it.
|
|
18
|
+
const db = drizzle.mock()
|
|
19
|
+
const dialect = new PgDialect()
|
|
20
|
+
const render = (query: SQL) => dialect.sqlToQuery(query)
|
|
21
|
+
|
|
22
|
+
const contacts = table("contacts", {
|
|
23
|
+
displayName: text(),
|
|
24
|
+
email: text(),
|
|
25
|
+
id: primaryId("sequential"),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const dealsProperties = table("deals_properties", {
|
|
29
|
+
dealId: text().notNull(),
|
|
30
|
+
propertyId: text().notNull(),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("one", () => {
|
|
34
|
+
it("returns the only row", () => {
|
|
35
|
+
expect(one([{ id: 1 }])).toEqual({ id: 1 })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("throws on none and on several", () => {
|
|
39
|
+
expect(() => one([])).toThrow("Expected exactly one row, received 0")
|
|
40
|
+
expect(() => one([1, 2])).toThrow("Expected exactly one row, received 2")
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("maybeOne", () => {
|
|
45
|
+
it("returns the row, or undefined for none", () => {
|
|
46
|
+
expect(maybeOne([{ id: 1 }])).toEqual({ id: 1 })
|
|
47
|
+
expect(maybeOne([])).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("throws on several", () => {
|
|
51
|
+
expect(() => maybeOne([1, 2])).toThrow(
|
|
52
|
+
"Expected at most one row, received 2"
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("definedValues", () => {
|
|
58
|
+
it("drops undefined keys and keeps null", () => {
|
|
59
|
+
expect(definedValues({ a: 1, b: undefined, c: null })).toEqual({
|
|
60
|
+
a: 1,
|
|
61
|
+
c: null,
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe("excludedSet", () => {
|
|
67
|
+
const upsert = (values: Record<string, unknown>, set: Record<string, SQL>) =>
|
|
68
|
+
db
|
|
69
|
+
.insert(contacts)
|
|
70
|
+
.values({ id: 1, ...values })
|
|
71
|
+
.onConflictDoUpdate({ set, target: contacts.id })
|
|
72
|
+
.toSQL().sql
|
|
73
|
+
|
|
74
|
+
it("writes back every supplied column by its database name, never the target", () => {
|
|
75
|
+
const values = { displayName: "Ada", email: "ada@example.com", id: 1 }
|
|
76
|
+
|
|
77
|
+
expect(upsert(values, excludedSet(contacts, values))).toContain(
|
|
78
|
+
'do update set "display_name" = excluded."display_name", "email" = excluded."email"'
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("skips a key carrying undefined", () => {
|
|
83
|
+
const values = { displayName: undefined, email: "ada@example.com" }
|
|
84
|
+
const set = excludedSet(contacts, values)
|
|
85
|
+
|
|
86
|
+
expect(Object.keys(set)).toEqual(["email"])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("falls back to id = excluded.id when nothing else is supplied", () => {
|
|
90
|
+
const set = excludedSet(contacts, { displayName: undefined, id: 1 })
|
|
91
|
+
|
|
92
|
+
expect(upsert({}, set)).toContain('do update set "id" = excluded."id"')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("takes another conflict target", () => {
|
|
96
|
+
const values = { displayName: "Ada", email: "ada@example.com" }
|
|
97
|
+
const set = excludedSet(contacts, values, { target: "email" })
|
|
98
|
+
|
|
99
|
+
expect(Object.keys(set)).toEqual(["displayName"])
|
|
100
|
+
expect(render(one(Object.values(set))).sql).toBe('excluded."display_name"')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("falls back to the first target column that exists", () => {
|
|
104
|
+
const set = excludedSet(
|
|
105
|
+
dealsProperties,
|
|
106
|
+
{ dealId: "d", propertyId: "p" },
|
|
107
|
+
{ target: ["dealId", "propertyId"] }
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(Object.keys(set)).toEqual(["dealId"])
|
|
111
|
+
expect(render(one(Object.values(set))).sql).toBe('excluded."deal_id"')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("stays empty for a table without the default id target", () => {
|
|
115
|
+
expect(excludedSet(dealsProperties, {})).toEqual({})
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const billing = schema("billing")
|
|
120
|
+
const invoices = billing.table("invoices", { id: primaryId("sequential") })
|
|
121
|
+
const privateNotes = schema("private").table("notes", {
|
|
122
|
+
id: primaryId("sequential"),
|
|
123
|
+
})
|
|
124
|
+
const contactNames = view("contact_names").as((qb) =>
|
|
125
|
+
qb.select({ displayName: contacts.displayName }).from(contacts)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
describe("functionPermissionTables", () => {
|
|
129
|
+
const schemaObject = {
|
|
130
|
+
billing,
|
|
131
|
+
contactNames,
|
|
132
|
+
contacts,
|
|
133
|
+
invoices,
|
|
134
|
+
privateNotes,
|
|
135
|
+
relationsHelper: () => undefined,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
it("includes only public tables by default", () => {
|
|
139
|
+
expect([...functionPermissionTables(schemaObject)]).toEqual([
|
|
140
|
+
["contacts", "public"],
|
|
141
|
+
])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("maps each table to its own schema", () => {
|
|
145
|
+
expect(
|
|
146
|
+
new Map(
|
|
147
|
+
functionPermissionTables(schemaObject, {
|
|
148
|
+
schemas: ["public", "billing"],
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
).toEqual(
|
|
152
|
+
new Map([
|
|
153
|
+
["contacts", "public"],
|
|
154
|
+
["invoices", "billing"],
|
|
155
|
+
])
|
|
156
|
+
)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("accepts the result of defineRelations", () => {
|
|
160
|
+
const relations = defineRelations({ contacts, invoices })
|
|
161
|
+
|
|
162
|
+
expect([
|
|
163
|
+
...functionPermissionTables(relations, { schemas: ["billing"] }),
|
|
164
|
+
]).toEqual([["invoices", "billing"]])
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("rejects a bare name that two included schemas share", () => {
|
|
168
|
+
const publicInvoices = table("invoices", { id: primaryId("sequential") })
|
|
169
|
+
|
|
170
|
+
expect(() =>
|
|
171
|
+
functionPermissionTables(
|
|
172
|
+
{ invoices, publicInvoices },
|
|
173
|
+
{ schemas: ["public", "billing"] }
|
|
174
|
+
)
|
|
175
|
+
).toThrow('Table "invoices" exists in both')
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe("selectFunctionPermissions", () => {
|
|
180
|
+
const tables = functionPermissionTables(
|
|
181
|
+
{ contacts, invoices },
|
|
182
|
+
{ schemas: ["public", "billing"] }
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
// Records the statement and answers with a canned row: no database.
|
|
186
|
+
const fakeDb = (row: Record<string, boolean | null>) => {
|
|
187
|
+
const queries: SQL[] = []
|
|
188
|
+
const executor: QueryExecutor = {
|
|
189
|
+
execute: (query) => {
|
|
190
|
+
queries.push(query)
|
|
191
|
+
return Promise.resolve([row])
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
return { executor, queries }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it("calls all four functions in one statement, id to all but insert", async () => {
|
|
198
|
+
const { executor, queries } = fakeDb({
|
|
199
|
+
delete: false,
|
|
200
|
+
insert: true,
|
|
201
|
+
select: true,
|
|
202
|
+
update: null,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const permissions = await selectFunctionPermissions(executor, {
|
|
206
|
+
id: 7,
|
|
207
|
+
table: "invoices",
|
|
208
|
+
tables,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
expect(permissions).toEqual({
|
|
212
|
+
delete: false,
|
|
213
|
+
insert: true,
|
|
214
|
+
select: true,
|
|
215
|
+
update: false,
|
|
216
|
+
})
|
|
217
|
+
const query = render(one(queries))
|
|
218
|
+
expect(query.sql).toBe(
|
|
219
|
+
'select "billing"."can_select_invoices"("id" => $1) as "select", "billing"."can_insert_invoices"() as "insert", "billing"."can_update_invoices"("id" => $2) as "update", "billing"."can_delete_invoices"("id" => $3) as "delete"'
|
|
220
|
+
)
|
|
221
|
+
expect(query.params).toEqual([7, 7, 7])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("calls every function bare without an id, honouring prefix and schema", async () => {
|
|
225
|
+
const { executor, queries } = fakeDb({
|
|
226
|
+
delete: true,
|
|
227
|
+
insert: true,
|
|
228
|
+
select: true,
|
|
229
|
+
update: true,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
await selectFunctionPermissions(executor, {
|
|
233
|
+
functionSchema: "private",
|
|
234
|
+
prefix: "may",
|
|
235
|
+
table: "contacts",
|
|
236
|
+
tables,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
expect(render(one(queries)).sql).toBe(
|
|
240
|
+
'select "private"."may_select_contacts"() as "select", "private"."may_insert_contacts"() as "insert", "private"."may_update_contacts"() as "update", "private"."may_delete_contacts"() as "delete"'
|
|
241
|
+
)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("rejects a table outside the allowlist before building anything", async () => {
|
|
245
|
+
const { executor, queries } = fakeDb({})
|
|
246
|
+
|
|
247
|
+
await expect(
|
|
248
|
+
selectFunctionPermissions(executor, {
|
|
249
|
+
table: 'contacts"; drop table contacts; --',
|
|
250
|
+
tables,
|
|
251
|
+
})
|
|
252
|
+
).rejects.toThrow("No permission functions for table")
|
|
253
|
+
expect(queries).toHaveLength(0)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it("accepts a real Drizzle client", () => {
|
|
257
|
+
// Compile-time only: the structural type fits a postgres-js database.
|
|
258
|
+
const executor: QueryExecutor = db
|
|
259
|
+
expect(executor.execute).toBeTypeOf("function")
|
|
260
|
+
})
|
|
261
|
+
})
|