History

Expand scope coverage (#427)

Rhys Sullivan committed 6 months ago

Commit e30e2131 parent 1c5d8523 files · +201 −5Browse files

Unified · Split

3 files
  1. apps/cloud/src/services/tenant-isolation.node.test.ts +52 −0
  2. packages/core/storage-drizzle/src/adapter.ts +17 −5
  3. packages/core/storage-postgres/src/index.test.ts +132 −0
apps/cloud/src/services/tenant-isolation.node.test.ts+52−0
@@ -155,4 +155,56 @@ describe("tenant isolation (HTTP)", () => { · 154 unmodified lines
155 expect(result._tag).toBe("Left");155 expect(result._tag).toBe("Left");
156 }),156 }),
157 );157 );
158
159 it.effect("updating a same-namespace OpenAPI source in one org does not mutate another org", () =>
160 Effect.gen(function* () {
161 const orgA = `org_${crypto.randomUUID()}`;
162 const orgB = `org_${crypto.randomUUID()}`;
163 const namespace = `shared_${crypto.randomUUID().replace(/-/g, "_")}`;
164
165 yield* asOrg(orgA, (client) =>
166 client.openapi.addSpec({
167 path: { scopeId: ScopeId.make(orgA) },
168 payload: {
169 spec: MINIMAL_OPENAPI_SPEC,
170 namespace,
171 name: "Org A API",
172 baseUrl: "https://org-a.example.com",
173 },
174 }),
175 );
176 yield* asOrg(orgB, (client) =>
177 client.openapi.addSpec({
178 path: { scopeId: ScopeId.make(orgB) },
179 payload: {
180 spec: MINIMAL_OPENAPI_SPEC,
181 namespace,
182 name: "Org B API",
183 baseUrl: "https://org-b.example.com",
184 },
185 }),
186 );
187
188 yield* asOrg(orgA, (client) =>
189 client.openapi.updateSource({
190 path: { scopeId: ScopeId.make(orgA), namespace },
191 payload: {
192 name: "Org A Updated API",
193 baseUrl: "https://org-a-updated.example.com",
194 },
195 }),
196 );
197
198 const orgASource = yield* asOrg(orgA, (client) =>
199 client.openapi.getSource({ path: { scopeId: ScopeId.make(orgA), namespace } }),
200 );
201 const orgBSource = yield* asOrg(orgB, (client) =>
202 client.openapi.getSource({ path: { scopeId: ScopeId.make(orgB), namespace } }),
203 );
204 expect(orgASource?.name).toBe("Org A Updated API");
205 expect(orgASource?.config.baseUrl).toBe("https://org-a-updated.example.com");
206 expect(orgBSource?.name).toBe("Org B API");
207 expect(orgBSource?.config.baseUrl).toBe("https://org-b.example.com");
208 }),
209 );
158});210});
packages/core/storage-drizzle/src/adapter.ts+17−5
@@ -212,6 +212,17 @@ const compileWhere = ( · 211 unmodified lines
212 return andClause ?? orClause;212 return andClause ?? orClause;
213};213};
214 214
215const rowIdentityClause = (
216 table: AnyTable,
217 row: Record<string, unknown>,
218): SQL => {
219 const idClause = eq(table.id, row.id);
220 if (table.scope_id && typeof row.scope_id === "string") {
221 return and(eq(table.scope_id, row.scope_id), idClause) as SQL;
222 }
223 return idClause;
224};
225
215// ---------------------------------------------------------------------------226// ---------------------------------------------------------------------------
216// Join → drizzle `with` clause227// Join → drizzle `with` clause
217//228//
@@ -592,7 +603,8 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 374 unmodified lines
592 if (matched.length === 0) return null;603 if (matched.length === 0) return null;
593 if (matched.length > 1) return null;604 if (matched.length > 1) return null;
594 const target = matched[0]!;605 const target = matched[0]!;
595 let updQ = db.update(table).set(update).where(eq(table.id, target.id));606 const identity = rowIdentityClause(table, target);
607 let updQ = db.update(table).set(update).where(identity);
596 if (provider !== "mysql") {608 if (provider !== "mysql") {
597 const rows = (yield* runPromise(609 const rows = (yield* runPromise(
598 "update returning",610 "update returning",
@@ -608,7 +620,7 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 9 unmodified lines
608 );620 );
609 const reread = (yield* runPromise(621 const reread = (yield* runPromise(
610 "mysql update reread",622 "mysql update reread",
611 () => db.select().from(table).where(eq(table.id, target.id)).limit(1),623 () => db.select().from(table).where(identity).limit(1),
612 model,624 model,
613 )) as Record<string, unknown>[];625 )) as Record<string, unknown>[];
614 return (reread[0] ?? null) as never;626 return (reread[0] ?? null) as never;
@@ -652,18 +664,18 @@ export const drizzleAdapter = (options: DrizzleAdapterOptions): DBAdapter => { · 37 unmodified lines
652 const table = getTable(model);664 const table = getTable(model);
653 const clause = compileWhere(table, where, provider);665 const clause = compileWhere(table, where, provider);
654 // Mirror in-memory semantics: delete first matching row only666 // Mirror in-memory semantics: delete first matching row only
655 let findQ = db.select({ id: table.id }).from(table);667 let findQ = db.select().from(table);
656 if (clause) findQ = findQ.where(clause);668 if (clause) findQ = findQ.where(clause);
657 const matched = (yield* runPromise(669 const matched = (yield* runPromise(
658 "delete pre-select",670 "delete pre-select",
659 () => findQ.limit(1),671 () => findQ.limit(1),
660 model,672 model,
661 )) as { id: unknown }[];673 )) as Record<string, unknown>[];
662 const first = matched[0];674 const first = matched[0];
663 if (!first) return;675 if (!first) return;
664 yield* runPromise(676 yield* runPromise(
665 "delete exec",677 "delete exec",
666 () => Promise.resolve(db.delete(table).where(eq(table.id, first.id))),678 () => Promise.resolve(db.delete(table).where(rowIdentityClause(table, first))),
667 model,679 model,
668 );680 );
669 }).pipe(681 }).pipe(
packages/core/storage-postgres/src/index.test.ts+132−0
@@ -6,12 +6,14 @@ · 5 unmodified lines
6// pattern apps/cloud uses). Port 5435 so it doesn't clash with the6// pattern apps/cloud uses). Port 5435 so it doesn't clash with the
7// cloud test DB on 5434.7// cloud test DB on 5434.
8 8
9import { describe, expect, it } from "@effect/vitest";
9import { Effect } from "effect";10import { Effect } from "effect";
10import postgres from "postgres";11import postgres from "postgres";
11import { drizzle } from "drizzle-orm/postgres-js";12import { drizzle } from "drizzle-orm/postgres-js";
12import { relations } from "drizzle-orm";13import { relations } from "drizzle-orm";
13import {14import {
14 pgTable,15 pgTable,
16 primaryKey,
15 text,17 text,
16 doublePrecision,18 doublePrecision,
17 boolean,19 boolean,
@@ -68,6 +70,16 @@ const with_defaults = pgTable("with_defaults", { · 50 unmodified lines
68 touchedAt: timestamp("touchedAt"),70 touchedAt: timestamp("touchedAt"),
69});71});
70 72
73const scoped_item = pgTable(
74 "scoped_item",
75 {
76 id: text("id").notNull(),
77 scope_id: text("scope_id").notNull(),
78 label: text("label"),
79 },
80 (table) => [primaryKey({ columns: [table.scope_id, table.id] })],
81);
82
71const sourceRelations = relations(source, ({ many }) => ({83const sourceRelations = relations(source, ({ many }) => ({
72 source_tag: many(source_tag),84 source_tag: many(source_tag),
73}));85}));
@@ -157,3 +169,123 @@ const withAdapter = <A, E>( · 83 unmodified lines
157 }) as Effect.Effect<A, E | Error>;169 }) as Effect.Effect<A, E | Error>;
158 170
159runAdapterConformance("postgres", withAdapter);171runAdapterConformance("postgres", withAdapter);
172
173const scopedSchema = {
174 scoped_item: {
175 fields: {
176 scope_id: { type: "string", required: true, index: true },
177 label: { type: "string", required: true },
178 },
179 },
180} as const;
181
182const resetScopedTable = Effect.tryPromise({
183 try: async () => {
184 await sql.unsafe(`DROP TABLE IF EXISTS "scoped_item" CASCADE`);
185 await sql.unsafe(
186 `CREATE TABLE "scoped_item" (
187 "id" TEXT NOT NULL,
188 "scope_id" TEXT NOT NULL,
189 "label" TEXT,
190 PRIMARY KEY ("scope_id", "id")
191 )`,
192 );
193 },
194 catch: (cause) =>
195 new Error(`failed to reset scoped_item table: ${String(cause)}`),
196});
197
198const makeScopedAdapter = () =>
199 makePostgresAdapter({
200 db: drizzle(sql, { schema: { scoped_item } }),
201 schema: scopedSchema,
202 });
203
204describe("postgres scoped row identity", () => {
205 it.effect("update pins composite identity when id is reused across scopes", () =>
206 Effect.gen(function* () {
207 yield* resetScopedTable;
208 const adapter = makeScopedAdapter();
209
210 yield* adapter.create({
211 model: "scoped_item",
212 forceAllowId: true,
213 data: { id: "shared", scope_id: "scope-a", label: "a" } as never,
214 });
215 yield* adapter.create({
216 model: "scoped_item",
217 forceAllowId: true,
218 data: { id: "shared", scope_id: "scope-b", label: "b" } as never,
219 });
220
221 yield* adapter.update({
222 model: "scoped_item",
223 where: [
224 { field: "id", value: "shared" },
225 { field: "scope_id", value: "scope-a" },
226 ],
227 update: { label: "a-updated" },
228 });
229
230 const scopeA = yield* adapter.findOne<{ label: string }>({
231 model: "scoped_item",
232 where: [
233 { field: "id", value: "shared" },
234 { field: "scope_id", value: "scope-a" },
235 ],
236 });
237 const scopeB = yield* adapter.findOne<{ label: string }>({
238 model: "scoped_item",
239 where: [
240 { field: "id", value: "shared" },
241 { field: "scope_id", value: "scope-b" },
242 ],
243 });
244 expect(scopeA?.label).toBe("a-updated");
245 expect(scopeB?.label).toBe("b");
246 }),
247 );
248
249 it.effect("delete pins composite identity when id is reused across scopes", () =>
250 Effect.gen(function* () {
251 yield* resetScopedTable;
252 const adapter = makeScopedAdapter();
253
254 yield* adapter.create({
255 model: "scoped_item",
256 forceAllowId: true,
257 data: { id: "shared", scope_id: "scope-a", label: "a" } as never,
258 });
259 yield* adapter.create({
260 model: "scoped_item",
261 forceAllowId: true,
262 data: { id: "shared", scope_id: "scope-b", label: "b" } as never,
263 });
264
265 yield* adapter.delete({
266 model: "scoped_item",
267 where: [
268 { field: "id", value: "shared" },
269 { field: "scope_id", value: "scope-a" },
270 ],
271 });
272
273 const scopeA = yield* adapter.findOne<{ label: string }>({
274 model: "scoped_item",
275 where: [
276 { field: "id", value: "shared" },
277 { field: "scope_id", value: "scope-a" },
278 ],
279 });
280 const scopeB = yield* adapter.findOne<{ label: string }>({
281 model: "scoped_item",
282 where: [
283 { field: "id", value: "shared" },
284 { field: "scope_id", value: "scope-b" },
285 ],
286 });
287 expect(scopeA).toBeNull();
288 expect(scopeB?.label).toBe("b");
289 }),
290 );
291});