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
4 changes: 4 additions & 0 deletions server/src/__integration__/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export function ensureSchemaOnce(): Promise<void> {
* constraints; CASCADE on the parents handles it but listing explicitly
* keeps the intent visible + lets us spot-check leakage. */
const TABLES_TO_WIPE: readonly string[] = [
'course_invitation_acceptances',
'course_invitations',
'course_members',
'courses',
'agent_host_actions',
'agent_os_approvals',
'agent_os_session_leases',
Expand Down
297 changes: 297 additions & 0 deletions server/src/__integration__/courses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import assert from 'node:assert/strict'
import { after, before, beforeEach, test } from 'node:test'
import { createServer, type Server } from 'node:http'
import { WebSocket, type RawData } from 'ws'
import * as Y from 'yjs'
import { buildApiTestApp, ensureSchemaOnce, resetAllTables, seedUserMembership, teardownAll } from './_helpers.js'
import { createWsTicket } from '../auth.js'
import { ensureSchema } from '../db/migrate.js'
import { pool } from '../db/pool.js'
import { applyLocalUpdate } from '../documents/rooms.js'
import { attachWebSocket } from '../ws.js'

const OWNER = 'u-course-owner'
const LEARNER = 'u-course-learner'
let ownerServer: Server
let learnerServer: Server
let ownerUrl = ''
let learnerUrl = ''

async function listen(userId: string, withWebSocket = false): Promise<{ server: Server; url: string }> {
const app = await buildApiTestApp(userId)
return await new Promise((resolve) => {
const server = createServer(app)
if (withWebSocket) attachWebSocket(server)
server.listen(0, () => {
const address = server.address()
resolve({ server, url: `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}` })
})
})
}

before(async () => {
await ensureSchemaOnce()
const owner = await listen(OWNER); ownerServer = owner.server; ownerUrl = owner.url
const learner = await listen(LEARNER, true); learnerServer = learner.server; learnerUrl = learner.url
})
beforeEach(resetAllTables)
after(async () => { await teardownAll(ownerServer); if (learnerServer.listening) await new Promise<void>((resolve) => learnerServer.close(() => resolve())) })

async function seedCompany(companyId = 'co-courses'): Promise<void> {
await pool.query(`INSERT INTO companies (id,name,slug,owner_user_id) VALUES ($1,'Course test',$1,$2)`, [companyId, OWNER])
await seedUserMembership(OWNER, companyId, { email: 'owner@test.local', displayName: 'Owner' })
await pool.query(
`INSERT INTO projects (id,company_id,name,description,color,created_by,is_general)
VALUES ($1,$2,'General','','#64748b',$3,TRUE)`,
[`general-${companyId}`, companyId, OWNER],
)
await pool.query(`INSERT INTO users (id,email,display_name,tier,email_verified_at) VALUES ($1,$2,'Learner','pro',NOW())`, [LEARNER, 'learner@test.local'])
}

async function createCourse(name: string, companyId = 'co-courses') {
const response = await fetch(`${ownerUrl}/api/courses`, {
method: 'POST', headers: { 'content-type': 'application/json', 'x-company-id': companyId },
body: JSON.stringify({ name, description: `${name} description` }),
})
const raw = await response.text()
assert.equal(response.status, 201, raw)
return JSON.parse(raw) as { id: string; projectId: string; studyRoomId: string }
}

async function createInvitation(courseId: string, role: 'teacher' | 'learner', companyId = 'co-courses') {
const created = await fetch(`${ownerUrl}/api/courses/${courseId}/invitations`, {
method: 'POST', headers: { 'content-type': 'application/json', 'x-company-id': companyId },
body: JSON.stringify({ email: 'learner@test.local', role, expiresInDays: 7, maxUses: 1 }),
})
const createdRaw = await created.text()
assert.equal(created.status, 201, createdRaw)
return JSON.parse(createdRaw) as { token: string; id: string }
}

async function inviteAndAccept(courseId: string, role: 'teacher' | 'learner', companyId = 'co-courses') {
const invitation = await createInvitation(courseId, role, companyId)
const accepted = await fetch(`${learnerUrl}/api/course-invitations/${encodeURIComponent(invitation.token)}/accept`, { method: 'POST' })
const acceptedRaw = await accepted.text()
assert.equal(accepted.status, 200, acceptedRaw)
return invitation
}

test('[integration] legacy Projects migrate to one idempotent Course and Study Room', async () => {
await pool.query(`DELETE FROM course_schema_cutovers WHERE id='course-model-v1'`)
await seedCompany('co-legacy')
await pool.query(
`INSERT INTO projects (id,company_id,name,description,color,created_by,is_general)
VALUES ('legacy-project','co-legacy','Legacy Biology','','#123456',$1,FALSE)`, [OWNER],
)
await pool.query(
`INSERT INTO company_members (company_id,user_id,role) VALUES ('co-legacy',$1,'member')`, [LEARNER],
)
await pool.query(
`INSERT INTO participants (id,company_id,kind,name,initial,avatar_bg,status)
VALUES ($1,'co-legacy','human','Learner','L','#aaa','avail')`, [LEARNER],
)

await ensureSchema()
const course = await pool.query<{ id: string; study_room_conversation_id: string }>(
`SELECT id,study_room_conversation_id FROM courses WHERE project_id='legacy-project'`,
)
assert.equal(course.rowCount, 1)
const roles = await pool.query<{ user_id: string; role: string }>(
`SELECT user_id,role FROM course_members WHERE course_id=$1 ORDER BY user_id`, [course.rows[0].id],
)
assert.deepEqual(new Map(roles.rows.map((row) => [row.user_id, row.role])), new Map([[LEARNER, 'learner'], [OWNER, 'teacher']]))
assert.match(course.rows[0].study_room_conversation_id, /^course-room-/)
assert.equal((await pool.query(`SELECT 1 FROM conversations WHERE id=$1 AND project_id='legacy-project'`, [course.rows[0].study_room_conversation_id])).rowCount, 1)

await pool.query(`DELETE FROM course_members WHERE course_id=$1 AND user_id=$2`, [course.rows[0].id, LEARNER])
await ensureSchema()
assert.equal((await pool.query(
`SELECT 1 FROM course_members WHERE course_id=$1 AND user_id=$2`,
[course.rows[0].id, LEARNER],
)).rowCount, 0)
})

test('[integration] learner sees only enrolled courses and receives opaque 404 for another Project', async () => {
await seedCompany()
const first = await createCourse('Physics')
const second = await createCourse('Chemistry')
await inviteAndAccept(first.id, 'learner')
await pool.query(
`INSERT INTO documents (id,company_id,project_id,title,created_by) VALUES
('doc-first','co-courses',$1,'First',$3),('doc-second','co-courses',$2,'Second',$3)`,
[first.projectId, second.projectId, OWNER],
)

const courses = await fetch(`${learnerUrl}/api/courses`, { headers: { 'x-company-id': 'co-courses' } })
assert.equal(courses.status, 200)
assert.deepEqual((await courses.json() as Array<{ id: string }>).map((course) => course.id), [first.id])
const allowed = await fetch(`${learnerUrl}/api/documents`, { headers: { 'x-company-id': 'co-courses', 'x-project-id': first.projectId } })
assert.deepEqual((await allowed.json() as { documents: Array<{ id: string }> }).documents.map((document) => document.id), ['doc-first'])
const denied = await fetch(`${learnerUrl}/api/documents/doc-second`, { headers: { 'x-company-id': 'co-courses', 'x-project-id': second.projectId } })
assert.equal(denied.status, 404)
})

test('[integration] course invitation replay is idempotent and teacher upgrade never downgrades', async () => {
await seedCompany()
const course = await createCourse('Mathematics')
const learnerInvite = await inviteAndAccept(course.id, 'learner')
const replay = await fetch(`${learnerUrl}/api/course-invitations/${encodeURIComponent(learnerInvite.token)}/accept`, { method: 'POST' })
assert.equal(replay.status, 200)
assert.equal((await pool.query(`SELECT use_count FROM course_invitations WHERE token_hash=$1`, [learnerInvite.id])).rows[0].use_count, 1)

await inviteAndAccept(course.id, 'teacher')
assert.equal((await pool.query(`SELECT role FROM course_members WHERE course_id=$1 AND user_id=$2`, [course.id, LEARNER])).rows[0].role, 'teacher')
const downgrade = await inviteAndAccept(course.id, 'learner')
assert.equal((await pool.query(`SELECT role FROM course_members WHERE course_id=$1 AND user_id=$2`, [course.id, LEARNER])).rows[0].role, 'teacher')
assert.equal((await pool.query(`SELECT use_count FROM course_invitations WHERE token_hash=$1`, [downgrade.id])).rows[0].use_count, 0)

await fetch(`${ownerUrl}/api/courses/${course.id}/archive`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-company-id': 'co-courses' }, body: '{}' })
const write = await fetch(`${learnerUrl}/api/documents`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-company-id': 'co-courses', 'x-project-id': course.projectId }, body: JSON.stringify({ title: 'Blocked' }) })
assert.equal(write.status, 409)
})

test('[integration] concurrent teacher and learner invitations preserve the teacher role', async () => {
await seedCompany()
const course = await createCourse('Concurrency')
const [teacherInvite, learnerInvite] = await Promise.all([
createInvitation(course.id, 'teacher'),
createInvitation(course.id, 'learner'),
])
const responses = await Promise.all([teacherInvite, learnerInvite].map((invitation) => fetch(
`${learnerUrl}/api/course-invitations/${encodeURIComponent(invitation.token)}/accept`,
{ method: 'POST' },
)))
assert.deepEqual(responses.map((response) => response.status), [200, 200])
assert.equal((await pool.query(
`SELECT role FROM course_members WHERE course_id=$1 AND user_id=$2`,
[course.id, LEARNER],
)).rows[0].role, 'teacher')
})

test('[integration] removing a member invalidates replay of their consumed course invitation', async () => {
await seedCompany()
const course = await createCourse('Replay revocation')
const invitation = await inviteAndAccept(course.id, 'learner')

const removed = await fetch(`${ownerUrl}/api/courses/${course.id}/members/${LEARNER}`, {
method: 'DELETE', headers: { 'x-company-id': 'co-courses' },
})
assert.equal(removed.status, 200, await removed.text())
const replay = await fetch(
`${learnerUrl}/api/course-invitations/${encodeURIComponent(invitation.token)}/accept`,
{ method: 'POST' },
)
assert.equal(replay.status, 410, await replay.text())
assert.equal((await pool.query(
`SELECT 1 FROM course_members WHERE course_id=$1 AND user_id=$2`,
[course.id, LEARNER],
)).rowCount, 0)

const visible = await fetch(`${learnerUrl}/api/courses`, { headers: { 'x-company-id': 'co-courses' } })
assert.equal(visible.status, 200)
assert.deepEqual(await visible.json(), [])
})

test('[integration] concurrent company removals cannot delete every teacher from an active course', async () => {
await seedCompany()
const course = await createCourse('Teacher invariant')
const teachers = ['u-company-teacher-a', 'u-company-teacher-b']
await pool.query(
`INSERT INTO users (id,email,display_name,tier,email_verified_at) VALUES
($1,'teacher-a@test.local','Teacher A','pro',NOW()),
($2,'teacher-b@test.local','Teacher B','pro',NOW())`,
teachers,
)
await pool.query(
`INSERT INTO company_members (company_id,user_id,role) VALUES
('co-courses',$1,'member'),('co-courses',$2,'member')`,
teachers,
)
await pool.query(
`INSERT INTO course_members (course_id,company_id,user_id,role) VALUES
($1,'co-courses',$2,'teacher'),($1,'co-courses',$3,'teacher')`,
[course.id, ...teachers],
)
const removeOwnerFromCourse = await fetch(`${ownerUrl}/api/courses/${course.id}/members/${OWNER}`, {
method: 'DELETE', headers: { 'x-company-id': 'co-courses' },
})
assert.equal(removeOwnerFromCourse.status, 200, await removeOwnerFromCourse.text())

const removals = await Promise.all(teachers.map((teacherId) => fetch(
`${ownerUrl}/api/companies/co-courses/members/${teacherId}`,
{ method: 'DELETE', headers: { 'x-company-id': 'co-courses' } },
)))
assert.deepEqual(removals.map((response) => response.status).sort(), [200, 409])
assert.equal((await pool.query(
`SELECT COUNT(*)::int AS count FROM course_members WHERE course_id=$1 AND role='teacher'`,
[course.id],
)).rows[0].count, 1)
assert.equal((await pool.query(
`SELECT COUNT(*)::int AS count FROM company_members
WHERE company_id='co-courses' AND user_id=ANY($1::text[])`,
[teachers],
)).rows[0].count, 1)
})

function waitForSocketMessage(
socket: WebSocket,
predicate: (message: Record<string, unknown>) => boolean,
timeoutMs = 2_000,
): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
socket.off('message', onMessage)
reject(new Error('timed out waiting for WebSocket message'))
}, timeoutMs)
const onMessage = (raw: RawData) => {
let message: Record<string, unknown>
try { message = JSON.parse(raw.toString()) as Record<string, unknown> } catch { return }
if (!predicate(message)) return
clearTimeout(timeout)
socket.off('message', onMessage)
resolve(message)
}
socket.on('message', onMessage)
})
}

test('[integration] removing a course member revokes an existing document WebSocket subscription', async () => {
await seedCompany()
const course = await createCourse('Realtime security')
await inviteAndAccept(course.id, 'learner')
await pool.query(
`INSERT INTO documents (id,company_id,project_id,title,created_by)
VALUES ('doc-live','co-courses',$1,'Live document',$2)`,
[course.projectId, OWNER],
)

const { ticket } = await createWsTicket(LEARNER)
const socket = new WebSocket(`${learnerUrl.replace('http://', 'ws://')}/ws?t=${encodeURIComponent(ticket)}`)
await new Promise<void>((resolve, reject) => {
socket.once('open', resolve)
socket.once('error', reject)
})
const synced = waitForSocketMessage(socket, (message) => message.type === 'doc.sync' && message.documentId === 'doc-live')
socket.send(JSON.stringify({ type: 'doc.subscribe', documentId: 'doc-live' }))
await synced

const removed = await fetch(`${ownerUrl}/api/courses/${course.id}/members/${LEARNER}`, {
method: 'DELETE', headers: { 'x-company-id': 'co-courses' },
})
assert.equal(removed.status, 200, await removed.text())
assert.equal(socket.readyState, WebSocket.OPEN)

const receivedUpdates: Record<string, unknown>[] = []
const capture = (raw: RawData) => {
const message = JSON.parse(raw.toString()) as Record<string, unknown>
if (message.type === 'doc.update' && message.documentId === 'doc-live') receivedUpdates.push(message)
}
socket.on('message', capture)
const source = new Y.Doc()
source.getText('content').insert(0, 'must not leak')
await applyLocalUpdate('doc-live', 'co-courses', 'review-test', OWNER, Y.encodeStateAsUpdate(source))
await new Promise((resolve) => setTimeout(resolve, 200))
socket.off('message', capture)
assert.equal(receivedUpdates.length, 0)
socket.close()
})
32 changes: 32 additions & 0 deletions server/src/__integration__/polls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ async function voteViaHttp(messageId: string, optionIds: string[]): Promise<{ st
return { status: res.status, body: await res.json().catch(() => null) }
}

async function closeViaHttp(messageId: string): Promise<{ status: number; body: any }> {
const res = await fetch(`${baseUrl}/api/polls/${encodeURIComponent(messageId)}/close`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-company-id': COMPANY },
body: '{}',
})
return { status: res.status, body: await res.json().catch(() => null) }
}

test('[integration] POST /polls creates a poll message with structured payload', async () => {
const { status, body } = await createPollViaHttp({
conversationId: CONVO,
Expand Down Expand Up @@ -219,6 +228,29 @@ test('[integration] closing a poll blocks further votes; only author can close',
assert.equal(vote.status, 409)
})

test('[integration] archived course conversations reject poll create, vote, and close writes', async () => {
await pool.query(
`INSERT INTO projects (id,company_id,name,description,color,created_by,is_general,status)
VALUES ('poll-course-project',$1,'Poll course','','#123456',$2,FALSE,'active')`,
[COMPANY, ME],
)
await pool.query(`UPDATE conversations SET project_id='poll-course-project' WHERE id=$1`, [CONVO])
const created = await createPollViaHttp({
conversationId: CONVO, question: 'Before archive?', mode: 'single', options: ['Yes', 'No'],
})
assert.equal(created.status, 201)
await pool.query(`UPDATE projects SET status='archived',archived_at=NOW() WHERE id='poll-course-project'`)

const createBlocked = await createPollViaHttp({
conversationId: CONVO, question: 'After archive?', mode: 'single', options: ['Yes', 'No'],
})
assert.equal(createBlocked.status, 409)
const voteBlocked = await voteViaHttp(created.body.messageId, [created.body.poll.options[0].id])
assert.equal(voteBlocked.status, 409)
const closeBlocked = await closeViaHttp(created.body.messageId)
assert.equal(closeBlocked.status, 409)
})

test('[integration] sweepExpiredPolls auto-closes polls past expiresAt', async () => {
const created = (await createPollViaHttp({
conversationId: CONVO, question: 'Now?', mode: 'single',
Expand Down
Loading
Loading