311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
jest.mock('../src/lib/supabase', () => ({
|
|
supabase: {
|
|
rpc: jest.fn(),
|
|
functions: { invoke: jest.fn() },
|
|
channel: jest.fn(),
|
|
removeChannel: jest.fn(async () => 'ok'),
|
|
from: jest.fn(),
|
|
},
|
|
}))
|
|
|
|
const mockedSupabase = jest.requireMock('../src/lib/supabase').supabase as {
|
|
rpc: jest.Mock
|
|
functions: { invoke: jest.Mock }
|
|
channel: jest.Mock
|
|
}
|
|
const mockRpc = mockedSupabase.rpc
|
|
const mockFunctionsInvoke = mockedSupabase.functions.invoke
|
|
const mockRealtimeOn = jest.fn()
|
|
const mockRealtimeSubscribe = jest.fn()
|
|
const mockRealtimeChannel: Record<string, unknown> = {
|
|
on: mockRealtimeOn,
|
|
subscribe: mockRealtimeSubscribe,
|
|
}
|
|
mockRealtimeOn.mockReturnValue(mockRealtimeChannel)
|
|
mockRealtimeSubscribe.mockReturnValue(mockRealtimeChannel)
|
|
mockedSupabase.channel.mockReturnValue(mockRealtimeChannel)
|
|
|
|
import {
|
|
acceptTeamInvite,
|
|
cancelTeamInvite,
|
|
createTeam,
|
|
createTeamInvite,
|
|
extractInviteToken,
|
|
normalizeInviteEmail,
|
|
normalizeInviteToken,
|
|
normalizeInviteUrl,
|
|
normalizeTeamInvite,
|
|
normalizeTeamMember,
|
|
normalizeTeamName,
|
|
subscribeToTeam,
|
|
TeamServiceError,
|
|
updateTeamMemberRole,
|
|
} from '../src/features/teams/team-service'
|
|
|
|
const USER_ID = '11111111-1111-4111-8111-111111111111'
|
|
const MEMBER_ID = '22222222-2222-4222-8222-222222222222'
|
|
const TEAM_ID = '33333333-3333-4333-8333-333333333333'
|
|
const INVITE_ID = '44444444-4444-4444-8444-444444444444'
|
|
const TOKEN = 'Abcdefghijklmnopqrstuvwxyz_12345'
|
|
|
|
describe('team input and response contracts', () => {
|
|
test('normalizes bounded names and email addresses', () => {
|
|
expect(normalizeTeamName(' Product Voice ')).toBe('Product Voice')
|
|
expect(normalizeInviteEmail(' MEMBER@Example.COM ')).toBe('member@example.com')
|
|
expect(() => normalizeTeamName(' ')).toThrow(TeamServiceError)
|
|
expect(() => normalizeTeamName('a'.repeat(81))).toThrow(TeamServiceError)
|
|
expect(() => normalizeInviteEmail('not-an-email')).toThrow(TeamServiceError)
|
|
})
|
|
|
|
test('extracts only URL-safe 24-byte invitation tokens', () => {
|
|
expect(normalizeInviteToken(TOKEN)).toBe(TOKEN)
|
|
expect(extractInviteToken(`https://d3ro.chanpaca.net/accept-invite?token=${TOKEN}`)).toBe(TOKEN)
|
|
expect(extractInviteToken(`d3ro-voice://accept-invite?token=${TOKEN}&source=email`)).toBe(TOKEN)
|
|
expect(() => extractInviteToken('https://d3ro.chanpaca.net/accept-invite?token=short'))
|
|
.toThrow('Invite token is invalid')
|
|
expect(() => extractInviteToken('https://d3ro.chanpaca.net/accept-invite'))
|
|
.toThrow('does not contain a token')
|
|
})
|
|
|
|
test('accepts only the production invitation origin and exact bearer URL shape', () => {
|
|
expect(normalizeInviteUrl(`https://d3ro.chanpaca.net/accept-invite/?token=${TOKEN}`))
|
|
.toBe(`https://d3ro.chanpaca.net/accept-invite/?token=${TOKEN}`)
|
|
expect(() => normalizeInviteUrl(`https://evil.example/accept-invite?token=${TOKEN}`))
|
|
.toThrow('not trusted')
|
|
expect(() => normalizeInviteUrl(`https://d3ro.chanpaca.net/accept-invite/?token=${TOKEN}&next=https://evil.example`))
|
|
.toThrow('not trusted')
|
|
expect(normalizeInviteUrl(`https://d3ro.chanpaca.net/accept-invite?token=${TOKEN}`))
|
|
.toBe(`https://d3ro.chanpaca.net/accept-invite?token=${TOKEN}`)
|
|
expect(() => normalizeInviteUrl(`https://d3ro.chanpaca.net/other?token=${TOKEN}`))
|
|
.toThrow('not trusted')
|
|
expect(() => normalizeInviteUrl(`https://d3ro.chanpaca.net/accept-invite-evil?token=${TOKEN}`))
|
|
.toThrow('not trusted')
|
|
})
|
|
|
|
test('accepts sanitized member directory rows', () => {
|
|
expect(normalizeTeamMember({
|
|
user_id: MEMBER_ID,
|
|
role: 'member',
|
|
joined_at: '2026-08-21T00:00:00.000Z',
|
|
display_name: 'Member',
|
|
avatar_url: null,
|
|
is_current: false,
|
|
})).toEqual({
|
|
userId: MEMBER_ID,
|
|
role: 'member',
|
|
joinedAt: '2026-08-21T00:00:00.000Z',
|
|
displayName: 'Member',
|
|
avatarUrl: null,
|
|
})
|
|
})
|
|
|
|
test('rejects any bearer token leaked by the invitation listing RPC', () => {
|
|
expect(() => normalizeTeamInvite({
|
|
id: INVITE_ID,
|
|
email: 'member@example.com',
|
|
role: 'member',
|
|
expires_at: '2026-08-28T00:00:00.000Z',
|
|
created_at: '2026-08-21T00:00:00.000Z',
|
|
token: TOKEN,
|
|
})).toThrow('leaked a bearer token')
|
|
expect(() => normalizeTeamInvite({
|
|
id: INVITE_ID,
|
|
email: 'member@example.com',
|
|
role: 'member',
|
|
expires_at: '2026-08-28T00:00:00.000Z',
|
|
created_at: '2026-08-21T00:00:00.000Z',
|
|
invite_token: TOKEN,
|
|
})).toThrow('leaked a bearer token')
|
|
})
|
|
})
|
|
|
|
describe('team mutation safety', () => {
|
|
beforeEach(() => {
|
|
mockRpc.mockReset()
|
|
mockFunctionsInvoke.mockReset()
|
|
})
|
|
|
|
test('creates owner membership only through the atomic create_team RPC', async () => {
|
|
mockRpc.mockResolvedValue({
|
|
data: {
|
|
id: TEAM_ID,
|
|
name: 'Product',
|
|
owner_id: USER_ID,
|
|
role: 'owner',
|
|
member_count: 1,
|
|
created_at: '2026-08-21T00:00:00.000Z',
|
|
updated_at: '2026-08-21T00:00:00.000Z',
|
|
},
|
|
error: null,
|
|
})
|
|
|
|
await expect(createTeam(USER_ID, ' Product ')).resolves.toMatchObject({
|
|
id: TEAM_ID,
|
|
role: 'owner',
|
|
memberCount: 1,
|
|
avatar_url: null,
|
|
})
|
|
expect(mockRpc).toHaveBeenCalledWith('create_team', { team_name: 'Product' })
|
|
})
|
|
|
|
test('changes roles through the invariant-preserving RPC and verifies the new directory row', async () => {
|
|
mockRpc
|
|
.mockResolvedValueOnce({ data: { user_id: MEMBER_ID, role: 'admin' }, error: null })
|
|
.mockResolvedValueOnce({
|
|
data: [{
|
|
user_id: MEMBER_ID,
|
|
role: 'admin',
|
|
joined_at: '2026-08-21T00:00:00.000Z',
|
|
display_name: 'Member',
|
|
avatar_url: null,
|
|
}],
|
|
error: null,
|
|
})
|
|
|
|
await expect(updateTeamMemberRole(TEAM_ID, MEMBER_ID, 'admin'))
|
|
.resolves.toMatchObject({ userId: MEMBER_ID, role: 'admin' })
|
|
expect(mockRpc).toHaveBeenNthCalledWith(1, 'update_team_member_role', {
|
|
target_team_id: TEAM_ID,
|
|
member_user_id: MEMBER_ID,
|
|
new_role: 'admin',
|
|
})
|
|
expect(mockRpc).toHaveBeenNthCalledWith(2, 'list_team_members', {
|
|
target_team_id: TEAM_ID,
|
|
})
|
|
})
|
|
|
|
test('does not allow a client to request owner promotion', async () => {
|
|
await expect(updateTeamMemberRole(TEAM_ID, MEMBER_ID, 'owner' as 'admin'))
|
|
.rejects.toMatchObject({ code: 'validation' })
|
|
expect(mockRpc).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('validates invitation function success and idempotent acceptance', async () => {
|
|
mockFunctionsInvoke
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
id: INVITE_ID,
|
|
url: `https://d3ro.chanpaca.net/accept-invite/?token=${TOKEN}`,
|
|
expires_at: '2026-08-28T00:00:00.000Z',
|
|
email_sent: false,
|
|
email_error: null,
|
|
},
|
|
error: null,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
data: {
|
|
team_id: TEAM_ID,
|
|
role: 'member',
|
|
already_member: true,
|
|
},
|
|
error: null,
|
|
})
|
|
|
|
await expect(createTeamInvite(TEAM_ID, 'MEMBER@example.com', 'member'))
|
|
.resolves.toMatchObject({ id: INVITE_ID, emailSent: false })
|
|
expect(mockFunctionsInvoke).toHaveBeenNthCalledWith(1, 'team-invite', {
|
|
body: { team_id: TEAM_ID, email: 'member@example.com', role: 'member' },
|
|
})
|
|
|
|
await expect(acceptTeamInvite(TOKEN)).resolves.toEqual({
|
|
teamId: TEAM_ID,
|
|
role: 'member',
|
|
alreadyMember: true,
|
|
})
|
|
})
|
|
|
|
test.each([
|
|
['invite_not_found', 404, 'invalid-token'],
|
|
['invite_expired', 410, 'expired'],
|
|
['invite_email_mismatch', 403, 'email-mismatch'],
|
|
['invite_already_accepted', 409, 'duplicate'],
|
|
['invite_acceptance_conflict', 409, 'conflict'],
|
|
])('maps team-accept Edge error %s without losing its domain meaning', async (
|
|
edgeCode,
|
|
status,
|
|
expectedCode,
|
|
) => {
|
|
mockFunctionsInvoke.mockResolvedValue({
|
|
data: null,
|
|
error: {
|
|
context: new Response(JSON.stringify({ error: edgeCode, message: edgeCode }), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
},
|
|
})
|
|
|
|
await expect(acceptTeamInvite(TOKEN)).rejects.toMatchObject({ code: expectedCode })
|
|
})
|
|
|
|
test.each([
|
|
['already_team_member', 409, 'duplicate'],
|
|
['team_admin_required', 403, 'forbidden'],
|
|
['invite_rate_limited', 429, 'rate-limited'],
|
|
])('maps team-invite Edge error %s without reporting generic server failure', async (
|
|
edgeCode,
|
|
status,
|
|
expectedCode,
|
|
) => {
|
|
mockFunctionsInvoke.mockResolvedValue({
|
|
data: null,
|
|
error: {
|
|
context: new Response(JSON.stringify({ error: edgeCode, message: edgeCode }), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' },
|
|
}),
|
|
},
|
|
})
|
|
|
|
await expect(createTeamInvite(TEAM_ID, 'member@example.com', 'member'))
|
|
.rejects.toMatchObject({ code: expectedCode })
|
|
})
|
|
|
|
test('maps a missing member RPC row to not-found instead of a retryable server error', async () => {
|
|
mockRpc.mockResolvedValue({
|
|
data: null,
|
|
error: { code: 'P0002', message: 'team_member_not_found' },
|
|
})
|
|
|
|
await expect(updateTeamMemberRole(TEAM_ID, MEMBER_ID, 'admin'))
|
|
.rejects.toMatchObject({ code: 'not-found' })
|
|
expect(mockRpc).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('keeps invite-not-found distinct for cancellation and bearer-token acceptance', async () => {
|
|
mockRpc.mockResolvedValue({
|
|
data: null,
|
|
error: { code: 'P0002', message: 'invite_not_found' },
|
|
})
|
|
await expect(cancelTeamInvite(TEAM_ID, INVITE_ID))
|
|
.rejects.toMatchObject({ code: 'not-found' })
|
|
|
|
mockFunctionsInvoke.mockResolvedValue({
|
|
data: null,
|
|
error: {
|
|
context: new Response(JSON.stringify({
|
|
error: 'invite_not_found',
|
|
message: 'invite_not_found',
|
|
}), { status: 404, headers: { 'content-type': 'application/json' } }),
|
|
},
|
|
})
|
|
await expect(acceptTeamInvite(TOKEN)).rejects.toMatchObject({ code: 'invalid-token' })
|
|
})
|
|
})
|
|
|
|
describe('team realtime contract', () => {
|
|
test('filters every mutable team table by the selected team', () => {
|
|
mockRealtimeOn.mockClear()
|
|
subscribeToTeam(TEAM_ID, jest.fn(), jest.fn())
|
|
const filters = mockRealtimeOn.mock.calls.map((call) => call[1] as {
|
|
table: string
|
|
filter: string
|
|
})
|
|
expect(filters).toEqual([
|
|
{ event: '*', schema: 'public', table: 'teams', filter: `id=eq.${TEAM_ID}` },
|
|
{ event: '*', schema: 'public', table: 'team_members', filter: `team_id=eq.${TEAM_ID}` },
|
|
{ event: '*', schema: 'public', table: 'team_invites', filter: `team_id=eq.${TEAM_ID}` },
|
|
{ event: '*', schema: 'public', table: 'meetings', filter: `team_id=eq.${TEAM_ID}` },
|
|
])
|
|
})
|
|
})
|