diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 083fc8a3edf..6ad195ff86d 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -93,6 +93,7 @@ jobs: lib/auth/oauth-provider-lifecycle.postgres.test.ts app/api/auth/oauth2/token/route.postgres.test.ts lib/auth/sim-auth-adapter.test.ts + lib/auth/sim-auth-adapter.postgres.test.ts ee/scim/lib/managed-membership.postgres.test.ts lib/auth/sso/application/admit-sso-user.postgres.test.ts diff --git a/apps/sim/lib/auth/sim-auth-adapter.postgres.test.ts b/apps/sim/lib/auth/sim-auth-adapter.postgres.test.ts new file mode 100644 index 00000000000..e0c71ca48d9 --- /dev/null +++ b/apps/sim/lib/auth/sim-auth-adapter.postgres.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import * as schema from '@sim/db/schema' +import { withUtcTimestamps } from '@sim/db/timestamps' +import { generateId } from '@sim/utils/id' +import type { BetterAuthOptions } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { organization } from 'better-auth/plugins' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { describe, expect, it, vi } from 'vitest' +import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter' + +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') + +/** The guard suites cover authorization; this exercises adapter SQL against the real table shape. */ +vi.mock('@/lib/auth/oauth-provider-adapter-guard', () => ({ + guardOAuthProviderWrites: (adapter: object) => adapter, +})) +vi.mock('@/lib/auth/stripe-adapter-guard', () => ({ + guardSubscriptionPlanWrites: (adapter: object) => adapter, +})) + +const OPTIONS: BetterAuthOptions = { plugins: [organization()] } +const databaseUrl = process.env.OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL +type AdapterSurface = Omit, 'transaction'> + +async function exerciseOrganization(adapter: AdapterSurface) { + const id = generateId() + const data = { id, name: 'Example', slug: id, createdAt: new Date('2026-01-01T00:00:00Z') } + const where = [{ field: 'id', value: id }] + + await expect( + adapter.create({ model: 'organization', data, forceAllowId: true }) + ).resolves.toMatchObject(data) + await expect(adapter.findOne({ model: 'organization', where })).resolves.toMatchObject(data) + await expect(adapter.findMany({ model: 'organization', where })).resolves.toEqual([ + expect.objectContaining(data), + ]) + await expect( + adapter.update({ model: 'organization', where, update: { name: 'Renamed' } }) + ).resolves.toMatchObject({ id, name: 'Renamed' }) + await adapter.delete({ model: 'organization', where }) + await expect(adapter.findOne({ model: 'organization', where })).resolves.toBeNull() +} + +describe.skipIf(!databaseUrl)('Better Auth across the organization column drop', () => { + it.each([false, true])('preserves CRUD with transaction=%s', async (transaction) => { + const client = postgres( + databaseUrl!, + withUtcTimestamps({ max: 1, prepare: false, fetch_types: false }) + ) + try { + /** The connection-local copy lets other integration suites keep using the public table. */ + await client`CREATE TEMP TABLE organization (LIKE public.organization INCLUDING ALL)` + const database = drizzle(client, { schema }) + const adapter = createSimAuthAdapter(OPTIONS, database) + const exercise = () => + transaction + ? adapter.transaction((tx) => exerciseOrganization(tx)) + : exerciseOrganization(adapter) + + await exercise() + await client`ALTER TABLE pg_temp.organization DROP COLUMN departed_member_usage` + + const unprojected = drizzleAdapter(database, { provider: 'pg', schema })(OPTIONS) + await expect( + unprojected.findOne({ model: 'organization', where: [{ field: 'id', value: 'missing' }] }) + ).rejects.toMatchObject({ cause: { code: '42703' } }) + + await exercise() + } finally { + await client.end() + } + }) +}) diff --git a/apps/sim/lib/auth/sim-auth-adapter.sql.test.ts b/apps/sim/lib/auth/sim-auth-adapter.sql.test.ts new file mode 100644 index 00000000000..a4e71fc7337 --- /dev/null +++ b/apps/sim/lib/auth/sim-auth-adapter.sql.test.ts @@ -0,0 +1,82 @@ +/** + * @vitest-environment node + */ +import * as schema from '@sim/db/schema' +import type { BetterAuthOptions } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { organization } from 'better-auth/plugins' +import { drizzle } from 'drizzle-orm/pg-proxy' +import { describe, expect, it, vi } from 'vitest' +import type { AuthDatabase } from '@/lib/auth/oauth-provider-adapter-guard' +import { createSimAuthAdapter } from '@/lib/auth/sim-auth-adapter' + +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') + +/** Guard behavior is covered separately; these tests exercise the real adapter and SQL builder. */ +vi.mock('@/lib/auth/oauth-provider-adapter-guard', () => ({ + guardOAuthProviderWrites: (adapter: object) => adapter, +})) +vi.mock('@/lib/auth/stripe-adapter-guard', () => ({ + guardSubscriptionPlanWrites: (adapter: object) => adapter, +})) + +const OPTIONS: BetterAuthOptions = { plugins: [organization()] } +type Adapter = ReturnType +type AdapterSurface = Omit +const WHERE = [{ field: 'id', value: 'organization-1' }] +const OPERATIONS: { name: string; run: (adapter: AdapterSurface) => Promise }[] = [ + { + name: 'findOne', + run: (adapter) => adapter.findOne({ model: 'organization', where: WHERE }), + }, + { + name: 'findMany', + run: (adapter) => adapter.findMany({ model: 'organization' }), + }, + { + name: 'create', + run: (adapter) => + adapter.create({ + model: 'organization', + data: { name: 'Example', slug: 'example', createdAt: new Date('2026-01-01T00:00:00Z') }, + }), + }, + { + name: 'update', + run: (adapter) => + adapter.update({ model: 'organization', where: WHERE, update: { name: 'Updated' } }), + }, + { + name: 'delete', + run: (adapter) => adapter.delete({ model: 'organization', where: WHERE }), + }, +] + +describe('Better Auth organization SQL', () => { + it.each(OPERATIONS)('excludes retired columns from $name', async (operation) => { + const execute = vi.fn(async (_query: string) => ({ rows: [] })) + const database = drizzle(execute) + const adapter = createSimAuthAdapter(OPTIONS, database as unknown as AuthDatabase) + + await operation.run(adapter) + + const queries = execute.mock.calls.map(([query]) => query) + expect( + queries.some((query) => query.includes('"organization"')), + operation.name + ).toBe(true) + for (const query of queries) { + expect(query, operation.name).not.toContain('"departed_member_usage"') + } + }) + + it('retains the full migration schema while the unprojected adapter remains incompatible', async () => { + const execute = vi.fn(async (_query: string) => ({ rows: [] })) + const adapter = drizzleAdapter(drizzle(execute), { provider: 'pg', schema })(OPTIONS) + + await adapter.findOne({ model: 'organization', where: WHERE }) + + expect(execute.mock.calls[0][0]).toContain('"departed_member_usage"') + }) +}) diff --git a/apps/sim/lib/auth/sim-auth-adapter.ts b/apps/sim/lib/auth/sim-auth-adapter.ts index b98fe240bbf..6ea6be9d05c 100644 --- a/apps/sim/lib/auth/sim-auth-adapter.ts +++ b/apps/sim/lib/auth/sim-auth-adapter.ts @@ -1,4 +1,5 @@ import { db } from '@sim/db' +import { withInsertColumns } from '@sim/db/insert-columns' import * as schema from '@sim/db/schema' import type { BetterAuthOptions } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' @@ -11,6 +12,12 @@ import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' type BetterAuthAdapter = ReturnType> +/** Better Auth's implicit reads, INSERT defaults, and RETURNING must use live columns. */ +const AUTH_SCHEMA = { + ...schema, + organization: withInsertColumns(schema.organization, schema.organizationColumns), +} + /** * Builds every Better Auth adapter surface, including transactional callbacks, * with Sim's write invariants applied to the actual Drizzle connection in use. @@ -22,7 +29,7 @@ export function createSimAuthAdapter( ): BetterAuthAdapter { const base = drizzleAdapter(database, { provider: 'pg', - schema, + schema: AUTH_SCHEMA, transaction: false, })(options) const guarded = guardSubscriptionPlanWrites(guardOAuthProviderWrites(base, database)) diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 1e58c2db153..e1229f4babb 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -205,36 +205,6 @@ describe('processContextsServer - block contexts', () => { isIntegrationDeploymentAvailable.mockReturnValue(true) }) - it('resolves integration mentions through the same metadata and access policy as blocks', async () => { - const contexts = await processContextsServer( - [ - { kind: 'integration', blockType: 'slack', label: 'Slack' }, - { kind: 'blocks', blockIds: ['slack'], label: 'Slack' }, - { kind: 'integration', blockType: 'notion', label: 'Notion' }, - { kind: 'integration', blockType: 'missing', label: 'Missing' }, - ], - 'user-1', - '', - 'workspace-1' - ) - - expect(contexts).toEqual([ - { type: 'blocks', tag: '@Slack', content: '', path: 'components/blocks/slack.json' }, - { type: 'blocks', tag: '@Slack', content: '', path: 'components/blocks/slack.json' }, - ]) - expect( - await resolveActiveResourceContext('integration', 'slack', 'workspace-1', 'user-1') - ).toEqual({ - type: 'active_resource', - tag: '@active_resource', - content: '', - path: 'components/blocks/slack.json', - }) - expect( - await resolveActiveResourceContext('integration', 'notion', 'workspace-1', 'user-1') - ).toBeNull() - }) - it('keeps access-control-exempt blocks while filtering non-exempt integrations', async () => { const result = await processContextsServer( [ diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 07af0cf9eb9..81c70bf7e4a 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1718,10 +1718,12 @@ export const organization = pgTable('organization', { .notNull() .default({}), /** - * contract-pending(after #7134 and #7774 are fully deployed): - * DROP departed_member_usage. Reads and inserts use organizationColumns; - * #7134 removed the v1 admin exposure and cycle-close resets. The pending-drop - * audit enforces the same read and insert constraints as user_stats. + * contract-pending(after #7134, #7774, and the Better Auth schema projection are fully deployed): + * DROP departed_member_usage. Application reads and inserts use + * organizationColumns; createSimAuthAdapter also projects the table for Better + * Auth's implicit reads, INSERT defaults, and RETURNING. Its projection must + * already be deployed before the drop; the pending-drop audit cannot inspect + * queries generated inside the auth dependency. */ /** @deprecated No readers or writers; a departed member's ledger rows stay stamped to the org's period, so nothing needs capturing. */ departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),