Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions harness-tests/core/chat-access-admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { PgDialect } from 'drizzle-orm/pg-core'
import type { SQL } from 'drizzle-orm'

const mocks = vi.hoisted(() => {
const rows: unknown[][] = []
const filters: (SQL | undefined)[] = []
return {
rows,
filters,
requireCapability: vi.fn(),
select: vi.fn(),
limit: vi.fn(),
offset: vi.fn(),
lock: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
createInvite: vi.fn(),
}
})
vi.mock('@tanstack/react-start/server', () => ({
getRequest: () => new Request('https://tanstack.com/admin/chat-access'),
}))
vi.mock('~/auth/index.server', () => ({
getAuthGuards: () => ({ requireCapability: mocks.requireCapability }),
}))
vi.mock('~/chat/access.server', () => ({
createChatInvite: mocks.createInvite,
}))
vi.mock('~/db/client', () => {
function query() {
const rows = mocks.rows.shift() ?? []
const chain = {
from: () => chain,
innerJoin: () => chain,
leftJoin: () => chain,
where: (filter: SQL | undefined) => {
mocks.filters.push(filter)
return chain
},
orderBy: () => chain,
limit: (size: number) => {
mocks.limit(size)
return chain
},
offset: (offset: number) => {
mocks.offset(offset)
return chain
},
returning: () => chain,
then: (resolve: (value: unknown[]) => unknown) =>
Promise.resolve(rows).then(resolve),
}
return chain
}
const db = {
select: (...args: unknown[]) => {
mocks.select(...args)
return query()
},
execute: mocks.lock,
insert: () => ({
values: (values: unknown) => {
mocks.insert(values)
return { onConflictDoNothing: vi.fn() }
},
}),
update: () => ({
set: (values: unknown) => {
mocks.update(values)
return query()
},
}),
transaction: (run: (tx: unknown) => unknown) => run(db),
}
return { db }
})
import {
createChatInviteAdmin,
expireChatInviteAdmin,
grantChatAccessAdmin,
listChatInvitesAdmin,
listChatWaitlistAdmin,
} from '~/chat/access-admin.server'

beforeEach(() => {
vi.clearAllMocks()
mocks.rows.length = 0
mocks.filters.length = 0
mocks.requireCapability.mockResolvedValue({ userId: 'admin' })
})

it.each([
{
name: 'waitlist',
run: () => listChatWaitlistAdmin({ page: 0, status: 'waiting' }),
},
{
name: 'invites',
run: () => listChatInvitesAdmin({ page: 0, status: 'pending' }),
},
{ name: 'grant', run: () => grantChatAccessAdmin('person') },
{ name: 'create', run: () => createChatInviteAdmin() },
{ name: 'expire', run: () => expireChatInviteAdmin('hash') },
])('requires admin before $name reads or writes', async ({ run }) => {
mocks.requireCapability.mockRejectedValue(new Error('Missing admin'))
await expect(run()).rejects.toThrow('Missing admin')
expect(mocks.requireCapability).toHaveBeenCalledWith(
expect.any(Request),
'admin',
)
expect(mocks.select).not.toHaveBeenCalled()
expect(mocks.insert).not.toHaveBeenCalled()
expect(mocks.update).not.toHaveBeenCalled()
expect(mocks.createInvite).not.toHaveBeenCalled()
})

it('bounds waitlist reads and sorts oldest requests first', async () => {
mocks.rows.push([], [{ total: 80 }])
expect(await listChatWaitlistAdmin({ page: 2, status: 'waiting' })).toEqual({
entries: [],
total: 80,
pageSize: 25,
})
expect(mocks.limit).toHaveBeenCalledWith(25)
expect(mocks.offset).toHaveBeenCalledWith(50)
const filter = mocks.filters[0]
if (!filter) throw new Error('Missing waiting filter')
expect(new PgDialect().sqlToQuery(filter).sql).toContain('is null')
})

it('only lists unredeemed, unexpired invites as pending', async () => {
mocks.rows.push([], [{ total: 3 }])
await listChatInvitesAdmin({ page: 0, status: 'pending' })
const filter = mocks.filters[0]
if (!filter) throw new Error('Missing pending filter')
const sql = new PgDialect().sqlToQuery(filter).sql
expect(sql).toContain('"redeemed_at" is null')
expect(sql).toContain('"expires_at" >')
expect(mocks.limit).toHaveBeenCalledWith(25)
})

it('locks the user before granting access and preserves an existing grant', async () => {
mocks.rows.push([{ userId: 'person' }])
await grantChatAccessAdmin('person')
expect(mocks.lock).toHaveBeenCalledOnce()
expect(mocks.lock.mock.invocationCallOrder[0]).toBeLessThan(
mocks.insert.mock.invocationCallOrder[0],
)
expect(mocks.insert).toHaveBeenCalledWith({
userId: 'person',
invitedBy: 'admin',
})
})

it('does not grant access to a user outside the waitlist', async () => {
mocks.rows.push([])
await expect(grantChatAccessAdmin('person')).rejects.toThrow(
'not on the TanChat waitlist',
)
expect(mocks.insert).not.toHaveBeenCalled()
})

it('creates admin invites through the existing hashed-token flow', async () => {
mocks.createInvite.mockResolvedValue({ token: 'new-invite' })
expect(await createChatInviteAdmin()).toEqual({ token: 'new-invite' })
expect(mocks.createInvite).toHaveBeenCalledWith({ userId: 'admin' })
})

it('expires only unused, active invites without deleting quota history', async () => {
mocks.rows.push([{ tokenHash: 'hash' }])
await expireChatInviteAdmin('hash')
expect(mocks.update).toHaveBeenCalledWith({ expiresAt: expect.any(Date) })
const filter = mocks.filters[0]
if (!filter) throw new Error('Missing expiry filter')
const sql = new PgDialect().sqlToQuery(filter).sql
expect(sql).toContain('"redeemed_at" is null')
expect(sql).toContain('"expires_at" >')
})

it('reports when redemption won the race against expiry', async () => {
mocks.rows.push([])
await expect(expireChatInviteAdmin('hash')).rejects.toThrow(
'already been used or expired',
)
})
37 changes: 37 additions & 0 deletions src/chat/access-admin.functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import {
listChatWaitlistAdmin,
listChatInvitesAdmin,
grantChatAccessAdmin,
createChatInviteAdmin,
expireChatInviteAdmin,
} from './access-admin.server'

const pagination = z.object({ page: z.number().int().min(0).max(100000) })

export const listChatWaitlist = createServerFn({ method: 'GET' })
.validator(
pagination.extend({ status: z.enum(['waiting', 'granted', 'all']) }),
)
.handler(({ data }) => listChatWaitlistAdmin(data))

export const listChatInvites = createServerFn({ method: 'GET' })
.validator(
pagination.extend({
status: z.enum(['pending', 'redeemed', 'expired', 'all']),
}),
)
.handler(({ data }) => listChatInvitesAdmin(data))

export const grantChatAccess = createServerFn({ method: 'POST' })
.validator(z.object({ userId: z.uuid() }))
.handler(({ data }) => grantChatAccessAdmin(data.userId))

export const createAdminChatInvite = createServerFn({ method: 'POST' }).handler(
() => createChatInviteAdmin(),
)

export const expireChatInvite = createServerFn({ method: 'POST' })
.validator(z.object({ tokenHash: z.string().regex(/^[a-f0-9]{64}$/) }))
.handler(({ data }) => expireChatInviteAdmin(data.tokenHash))
136 changes: 136 additions & 0 deletions src/chat/access-admin.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {
and,
asc,
desc,
eq,
gt,
isNotNull,
isNull,
lte,
sql,
} from 'drizzle-orm'
import { alias } from 'drizzle-orm/pg-core'
import { getRequest } from '@tanstack/react-start/server'
import { getAuthGuards } from '~/auth/index.server'
import { db } from '~/db/client'
import { chatAccess, chatInvites, chatWaitlist, users } from '~/db/schema'
import { createChatInvite } from './access.server'

const pageSize = 25

export async function listChatWaitlistAdmin(input: {
page: number
status: 'waiting' | 'granted' | 'all'
}) {
await getAuthGuards().requireCapability(getRequest(), 'admin')
const filter =
input.status === 'waiting'
? isNull(chatAccess.userId)
: input.status === 'granted'
? isNotNull(chatAccess.userId)
: undefined
const [entries, [count]] = await Promise.all([
db
.select({
userId: users.id,
name: users.name,
email: users.email,
joinedAt: chatWaitlist.createdAt,
grantedAt: chatAccess.createdAt,
})
.from(chatWaitlist)
.innerJoin(users, eq(users.id, chatWaitlist.userId))
.leftJoin(chatAccess, eq(chatAccess.userId, chatWaitlist.userId))
.where(filter)
.orderBy(asc(chatWaitlist.createdAt), asc(chatWaitlist.userId))
.limit(pageSize)
.offset(input.page * pageSize),
db
.select({ total: sql<number>`count(*)::int` })
.from(chatWaitlist)
.leftJoin(chatAccess, eq(chatAccess.userId, chatWaitlist.userId))
.where(filter),
])
return { entries, total: count.total, pageSize }
}

export async function listChatInvitesAdmin(input: {
page: number
status: 'pending' | 'redeemed' | 'expired' | 'all'
}) {
await getAuthGuards().requireCapability(getRequest(), 'admin')
const now = new Date()
const filter =
input.status === 'pending'
? and(isNull(chatInvites.redeemedAt), gt(chatInvites.expiresAt, now))
: input.status === 'redeemed'
? isNotNull(chatInvites.redeemedAt)
: input.status === 'expired'
? and(isNull(chatInvites.redeemedAt), lte(chatInvites.expiresAt, now))
: undefined
const recipient = alias(users, 'invite_recipient')
const [entries, [count]] = await Promise.all([
db
.select({
tokenHash: chatInvites.tokenHash,
createdBy: users.email,
redeemedBy: recipient.email,
createdAt: chatInvites.createdAt,
expiresAt: chatInvites.expiresAt,
redeemedAt: chatInvites.redeemedAt,
})
.from(chatInvites)
.innerJoin(users, eq(users.id, chatInvites.createdBy))
.leftJoin(recipient, eq(recipient.id, chatInvites.redeemedBy))
.where(filter)
.orderBy(desc(chatInvites.createdAt), asc(chatInvites.tokenHash))
.limit(pageSize)
.offset(input.page * pageSize),
db
.select({ total: sql<number>`count(*)::int` })
.from(chatInvites)
.where(filter),
])
return { entries, total: count.total, pageSize, serverTime: now }
}

export async function grantChatAccessAdmin(userId: string) {
const admin = await getAuthGuards().requireCapability(getRequest(), 'admin')
await db.transaction(async (tx) => {
// Use the same user lock as invite redemption so approval cannot race it.
await tx.execute(sql`select id from users where id = ${userId} for update`)
const [entry] = await tx
.select({ userId: chatWaitlist.userId })
.from(chatWaitlist)
.where(eq(chatWaitlist.userId, userId))
if (!entry) throw new Error('This user is not on the TanChat waitlist.')
await tx
.insert(chatAccess)
.values({ userId, invitedBy: admin.userId })
.onConflictDoNothing()
})
return { userId }
}

export async function createChatInviteAdmin() {
const admin = await getAuthGuards().requireCapability(getRequest(), 'admin')
return createChatInvite(admin)
}

export async function expireChatInviteAdmin(tokenHash: string) {
await getAuthGuards().requireCapability(getRequest(), 'admin')
const now = new Date()
const [invite] = await db
.update(chatInvites)
.set({ expiresAt: now })
.where(
and(
eq(chatInvites.tokenHash, tokenHash),
isNull(chatInvites.redeemedAt),
gt(chatInvites.expiresAt, now),
),
)
.returning({ tokenHash: chatInvites.tokenHash })
if (!invite) throw new Error('This invite has already been used or expired.')
return invite
}
Loading
Loading