Files
zpan/server/services/team-invite.integration.test.ts
T
878cdeb117 feat: team invitation via email and invite link (#302)
* feat: team invitation via email and invite link

- Add team invite dialog with email invite and shareable link tabs
- Email invite uses better-auth organizationClient.inviteMember() with configured email service
- Invite link generates a time-limited token stored in new team_invite_links table
- Accept invite page at /teams/invite?token=xxx (auto-join if logged in, redirect to sign-in if not)
- Pending invitations section shows all pending email invites; owners can cancel them
- Add editor/viewer custom roles to better-auth organization plugin
- Add sendInvitationEmail hook to send HTML invite email via configured email service
- Redirect-after-login support: _authenticated layout passes current URL to sign-in
- Add migration 0007_team_invite_links for new table
- Only team owners see the Invite Member button and pending invitations

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* test: add integration tests for team invite service and routes

Cover createInviteLink, getInviteLinkInfo, acceptInviteLink, and
listPendingInvitations service functions. Add route tests for all
public and authenticated team invite endpoints (invite-info, invite-link,
invitations list, and join). Add team_invite_links table to test setup.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* style: fix biome lint in team invite test files

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* fix: resolve CodeQL open-redirect and missing coverage issues

- Validate redirect param in sign-in.tsx is a same-origin relative path
  to prevent open redirect and javascript: URI XSS (CodeQL alerts)
- Spread defaultRoles (owner/admin/member) when configuring custom roles
  in organization plugin so built-in roles retain their permissions
- Add integration tests for sendInvitationEmail callback to cover
  buildInvitationEmailHtml and the email dispatch path in auth.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use URL constructor to sanitize redirect param in sign-in

Replace regex check with URL constructor origin validation so CodeQL's
dataflow analysis can confirm the value is same-origin before it reaches
window.location.href (resolves js/xss and js/client-side-unvalidated-url-redirection alerts).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:55:26 -04:00

238 lines
7.8 KiB
TypeScript

import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import { createTestApp } from '../test/setup.js'
import { acceptInviteLink, createInviteLink, getInviteLinkInfo, listPendingInvitations } from './team-invite.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
async function insertUser(db: TestDb, overrides: Partial<{ id: string; email: string }> = {}) {
const id = overrides.id ?? nanoid()
await db.insert(authSchema.user).values({
id,
name: 'Test User',
email: overrides.email ?? `${id}@example.com`,
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
})
return id
}
async function insertOrg(db: TestDb, overrides: Partial<{ id: string; name: string }> = {}) {
const id = overrides.id ?? nanoid()
await db.insert(authSchema.organization).values({
id,
name: overrides.name ?? 'Test Org',
slug: nanoid(),
createdAt: new Date(),
})
return id
}
async function insertMember(db: TestDb, organizationId: string, userId: string, role = 'owner') {
await db.insert(authSchema.member).values({
id: nanoid(),
organizationId,
userId,
role,
createdAt: new Date(),
})
}
async function insertInvitation(
db: TestDb,
organizationId: string,
inviterId: string,
email: string,
status = 'pending',
) {
const id = nanoid()
await db.insert(authSchema.invitation).values({
id,
organizationId,
email,
role: 'viewer',
status,
inviterId,
createdAt: new Date(),
})
return id
}
describe('createInviteLink', () => {
it('creates and returns a new invite link', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
expect(link.token).toBeTruthy()
expect(link.organizationId).toBe(orgId)
expect(link.role).toBe('viewer')
expect(link.inviterId).toBe(inviterId)
expect(link.expiresAt).toBeTruthy()
})
it('uses provided expiresIn to set expiry', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const oneHour = 60 * 60 * 1000
const before = Date.now()
const link = await createInviteLink(db, orgId, inviterId, 'editor', oneHour)
const after = Date.now()
expect(link.expiresAt!.getTime()).toBeGreaterThan(before + oneHour - 1000)
expect(link.expiresAt!.getTime()).toBeLessThan(after + oneHour + 1000)
})
it('generates unique tokens', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const [a, b] = await Promise.all([
createInviteLink(db, orgId, inviterId, 'viewer'),
createInviteLink(db, orgId, inviterId, 'viewer'),
])
expect(a.token).not.toBe(b.token)
})
})
describe('getInviteLinkInfo', () => {
it('returns invite info for a valid token', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db, { name: 'My Team' })
const inviterId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'editor')
const info = await getInviteLinkInfo(db, link.token)
expect(info).not.toBeNull()
expect(info!.organizationId).toBe(orgId)
expect(info!.organizationName).toBe('My Team')
expect(info!.role).toBe('editor')
})
it('returns null for an unknown token', async () => {
const { db } = await createTestApp()
const info = await getInviteLinkInfo(db, 'nonexistent-token')
expect(info).toBeNull()
})
it('returns null for an expired token', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer', -1000) // already expired
const info = await getInviteLinkInfo(db, link.token)
expect(info).toBeNull()
})
})
describe('acceptInviteLink', () => {
it('adds the user as a member and returns ok', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const userId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
const result = await acceptInviteLink(db, link.token, userId)
expect(result).toBe('ok')
})
it('returns invalid for a nonexistent token', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const result = await acceptInviteLink(db, 'bad-token', userId)
expect(result).toBe('invalid')
})
it('returns expired for an expired token', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const userId = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer', -1000)
const result = await acceptInviteLink(db, link.token, userId)
expect(result).toBe('expired')
})
it('returns already_member if user is already in the org', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const userId = await insertUser(db)
await insertMember(db, orgId, userId, 'viewer')
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
const result = await acceptInviteLink(db, link.token, userId)
expect(result).toBe('already_member')
})
it('allows the same link to be used multiple times (not one-time)', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
const user1 = await insertUser(db)
const user2 = await insertUser(db)
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
const r1 = await acceptInviteLink(db, link.token, user1)
const r2 = await acceptInviteLink(db, link.token, user2)
expect(r1).toBe('ok')
expect(r2).toBe('ok')
})
})
describe('listPendingInvitations', () => {
it('returns empty list when no pending invitations', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const result = await listPendingInvitations(db, orgId)
expect(result).toEqual([])
})
it('returns pending invitations for the organization', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
await insertInvitation(db, orgId, inviterId, 'test@example.com')
const result = await listPendingInvitations(db, orgId)
expect(result).toHaveLength(1)
expect(result[0].email).toBe('test@example.com')
expect(result[0].role).toBe('viewer')
})
it('excludes non-pending invitations', async () => {
const { db } = await createTestApp()
const orgId = await insertOrg(db)
const inviterId = await insertUser(db)
await insertInvitation(db, orgId, inviterId, 'accepted@example.com', 'accepted')
await insertInvitation(db, orgId, inviterId, 'pending@example.com', 'pending')
const result = await listPendingInvitations(db, orgId)
expect(result).toHaveLength(1)
expect(result[0].email).toBe('pending@example.com')
})
it('returns invitations for the specified org only', async () => {
const { db } = await createTestApp()
const org1 = await insertOrg(db)
const org2 = await insertOrg(db)
const inviterId = await insertUser(db)
await insertInvitation(db, org1, inviterId, 'org1@example.com')
await insertInvitation(db, org2, inviterId, 'org2@example.com')
const result = await listPendingInvitations(db, org1)
expect(result).toHaveLength(1)
expect(result[0].email).toBe('org1@example.com')
})
})