Skip to content
Open
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
3 changes: 2 additions & 1 deletion e2e/fixtures/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ export async function loginViaDashboard(
role: DashboardRole,
name: string,
landing: string,
options: { nameConfirmed?: boolean } = {},
): Promise<void> {
const response = await page.request.post('/api/test-login', {
data: { name, role, landing },
data: { name, role, landing, ...options },
});
expect(
response.ok(),
Expand Down
33 changes: 33 additions & 0 deletions e2e/fixtures/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,39 @@ export async function withoutContributions<T>(
}
}

/**
* Clear a fixture session's raised hands before and after `run`.
*
* Hand-flow specs exercise the real persistent queue. Keeping this cleanup
* close to those specs prevents their state from leaking into later cockpit
* screenshots while still allowing the product endpoints to own every
* transition under test.
*/
export async function withoutRaisedHands<T>(
databaseUrl: string,
sessionId: string,
run: () => Promise<T>,
): Promise<T> {
const client = new pg.Client({ connectionString: databaseUrl });
await client.connect();
try {
await client.query(
'update session_participants set raised_at = null where scheduled_session_id = $1',
[sessionId],
);
try {
return await run();
} finally {
await client.query(
'update session_participants set raised_at = null where scheduled_session_id = $1',
[sessionId],
);
}
} finally {
await client.end();
}
}

/** Replace fixture event titles for a layout test, then restore them exactly. */
export async function withSessionTitles<T>(
databaseUrl: string,
Expand Down
15 changes: 15 additions & 0 deletions e2e/tests/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,21 @@ test.describe('public surfaces', () => {
});

stackTest.describe('role surfaces', () => {
stackTest('attendee name confirmation is accessible before LiveKit mounts', async ({ page }, testInfo) => {
await loginViaDashboard(
page,
'ATTENDEE',
'Participante',
ROUTES.session(SESSION_ES.id),
{ nameConfirmed: false },
);
await expect(page.getByRole('textbox', {
name: /Tu nombre visible|Your visible name/i,
})).toBeVisible();
await expect(page.getByTestId('connection-state')).toHaveCount(0);
await assertAccessible(page, 'attendee-name-confirmation', testInfo);
});

stackTest('attendee session shell is accessible', async ({ page }, testInfo) => {
// Doors open so the attendee reaches the real shell; without LiveKit
// the deterministic connection-error card is checked instead.
Expand Down
105 changes: 105 additions & 0 deletions e2e/tests/attendee-identity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { expect, stackTest } from '../fixtures/stack';
import { loginAttendeeWithTicket, loginViaDashboard } from '../fixtures/auth';
import { requireDirectDb, withoutRaisedHands, withSessionStatus } from '../fixtures/db';
import { ROUTES, SESSION_ES, TICKETS } from '../fixtures/test-data';

stackTest('an unconfirmed attendee alias blocks LiveKit until it is corrected, then survives refresh', async ({
browser,
}, testInfo) => {
const db = requireDirectDb(testInfo);
await withSessionStatus(db, SESSION_ES.id, 'LIVE', async () => {
const context = await browser.newContext();
const page = await context.newPage();
try {
await loginViaDashboard(
page,
'ATTENDEE',
'Participante',
ROUTES.session(SESSION_ES.id),
{ nameConfirmed: false },
);

const input = page.getByRole('textbox', { name: /Tu nombre visible|Your visible name/i });
await expect(input).toBeVisible();
await expect(page.getByTestId('connection-state')).toHaveCount(0);
await input.fill('Anahí 李');
await page.getByRole('button', { name: /Confirmar y continuar|Confirm and continue/i }).click();

await expect(page.getByTestId('connection-state')).toHaveAttribute(
'data-state',
'connected',
{ timeout: 20_000 },
);
await expect(page.getByTestId('viewer-identity')).toContainText('Anahí 李');

await page.reload();
await expect(page.getByTestId('connection-state')).toHaveAttribute(
'data-state',
'connected',
{ timeout: 20_000 },
);
await expect(input).toHaveCount(0);
await expect(page.getByTestId('viewer-identity')).toContainText('Anahí 李');
} finally {
await context.close();
}
});
});

stackTest('a second device can correct the stable event alias used by the hand queue', async ({
browser,
}, testInfo) => {
stackTest.slow();
const db = requireDirectDb(testInfo);
await withSessionStatus(db, SESSION_ES.id, 'LIVE', async () => {
await withoutRaisedHands(db, SESSION_ES.id, async () => {
const firstContext = await browser.newContext();
const secondContext = await browser.newContext();
const staffContext = await browser.newContext();
const first = await firstContext.newPage();
const second = await secondContext.newPage();
const staff = await staffContext.newPage();
try {
await loginAttendeeWithTicket(first, {
name: 'Primer nombre',
email: TICKETS.esBound.email,
code: TICKETS.esBound.code,
});
await expect(first.getByTestId('viewer-identity')).toContainText('Primer nombre', {
timeout: 20_000,
});

await loginAttendeeWithTicket(second, {
name: 'Anahí 李',
email: TICKETS.esBound.email,
code: TICKETS.esBound.code,
});
await expect(second.getByTestId('viewer-identity')).toContainText('Anahí 李', {
timeout: 20_000,
});

await loginViaDashboard(
staff,
'OPERATOR',
'Identity Operator',
ROUTES.opsSession(SESSION_ES.id),
);
await staff.locator('[data-signal="hands"]').click();
await second.getByRole('button', { name: /Levantar la mano|Raise hand/i }).click();

const queue = staff
.getByRole('heading', { name: /Fila de manos|Hand queue/i })
.locator('..');
const correctedHand = queue.locator('li').filter({ hasText: 'Anahí 李' });
await expect(correctedHand).toHaveCount(1, {
timeout: 10_000,
});
await expect(queue.locator('li').filter({ hasText: 'Primer nombre' })).toHaveCount(0);
} finally {
await firstContext.close();
await secondContext.close();
await staffContext.close();
}
});
});
});
6 changes: 6 additions & 0 deletions e2e/tests/media-continuity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ async function leaveConnectedRoom(
const leave = surface.getByRole('button', { name: /Leave session|Salir de la sesión/i });
if (await leave.isVisible()) {
await leave.click();
const confirmation = surface.getByRole('alertdialog', {
name: /Leave session|Salir de la sesión/i,
});
await confirmation.getByRole('button', {
name: /Yes, leave the session|Sí, salir de la sesión/i,
}).click();
await expect(surface.getByTestId('connection-state')).toHaveCount(0);
}
}
Expand Down
24 changes: 23 additions & 1 deletion e2e/tests/stage-invitation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,32 @@ stackTest('a fresh connection stays invited until the attendee accepts the stage
await expect(stageRow.getByRole('button', { name: /Take floor|Quitar la palabra/i })).toBeVisible({
timeout: 10_000,
});
await stageRow.getByRole('button', { name: /Take floor|Quitar la palabra/i }).click();

// The attendee owns the return transition. It revokes publishing
// without leaving either receiving room or requiring a new audio
// activation, and is deliberately distinct from session exit.
await attendee.getByRole('button', { name: /Leave the scene|Dejar la escena/i }).click();
const leaveConfirmation = attendee.getByRole('alertdialog', {
name: /Return to the audience|volver al público/i,
});
await expect(leaveConfirmation).toContainText(/without reconnecting|sin reconectarte/i);
await leaveConfirmation.getByRole('button', {
name: /Yes, leave the scene|Sí, dejar la escena/i,
}).click();

await expect(
attendee.getByRole('button', { name: /Turn camera off|Apagar (?:la )?cámara/i }),
).toHaveCount(0, { timeout: 10_000 });
await expect(attendee.getByTestId('connection-state')).toHaveAttribute(
'data-state',
'connected',
);
await expect(attendee.getByRole('button', { name: /Raise hand|Levantar la mano/i })).toBeVisible({
timeout: 10_000,
});
await expect(stageRow.getByRole('button', { name: /Take floor|Quitar la palabra/i })).toHaveCount(0, {
timeout: 10_000,
});
} finally {
await attendeeContext.close();
await staffContext.close();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- A room alias is explicitly confirmed before an attendee mounts LiveKit.
-- Existing sessions remain unconfirmed so their next entry repairs any
-- historical generic alias instead of silently carrying it forward.
ALTER TABLE "web_sessions"
ADD COLUMN "display_name_confirmed_at" TIMESTAMP(3);
39 changes: 20 additions & 19 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -331,25 +331,26 @@ model CommerceMediaOutbox {
}

model WebSession {
id String @id @default(uuid()) @db.Uuid
tokenDigest String @unique @map("token_digest")
displayName String? @map("display_name")
accountIssuer String? @map("account_issuer")
accountSubject String? @map("account_subject")
accountSessionId String? @map("account_session_id")
accountDisplayName String? @map("account_display_name")
accountValidatedAt DateTime? @map("account_validated_at")
staffUserId String? @map("staff_user_id") @db.Uuid
staffUser User? @relation("StaffWebSessions", fields: [staffUserId], references: [id], onDelete: Cascade)
ticketEntitlementId String? @map("ticket_entitlement_id") @db.Uuid
ticketEntitlement TicketEntitlement? @relation(fields: [ticketEntitlementId], references: [id], onDelete: Cascade)
expiresAt DateTime @map("expires_at")
lastSeenAt DateTime? @map("last_seen_at")
revokedAt DateTime? @map("revoked_at")
revokedByUserId String? @map("revoked_by_user_id") @db.Uuid
revokedBy User? @relation("WebSessionRevoker", fields: [revokedByUserId], references: [id], onDelete: SetNull)
revocationReason String? @map("revocation_reason")
createdAt DateTime @default(now()) @map("created_at")
id String @id @default(uuid()) @db.Uuid
tokenDigest String @unique @map("token_digest")
displayName String? @map("display_name")
displayNameConfirmedAt DateTime? @map("display_name_confirmed_at")
accountIssuer String? @map("account_issuer")
accountSubject String? @map("account_subject")
accountSessionId String? @map("account_session_id")
accountDisplayName String? @map("account_display_name")
accountValidatedAt DateTime? @map("account_validated_at")
staffUserId String? @map("staff_user_id") @db.Uuid
staffUser User? @relation("StaffWebSessions", fields: [staffUserId], references: [id], onDelete: Cascade)
ticketEntitlementId String? @map("ticket_entitlement_id") @db.Uuid
ticketEntitlement TicketEntitlement? @relation(fields: [ticketEntitlementId], references: [id], onDelete: Cascade)
expiresAt DateTime @map("expires_at")
lastSeenAt DateTime? @map("last_seen_at")
revokedAt DateTime? @map("revoked_at")
revokedByUserId String? @map("revoked_by_user_id") @db.Uuid
revokedBy User? @relation("WebSessionRevoker", fields: [revokedByUserId], references: [id], onDelete: SetNull)
revocationReason String? @map("revocation_reason")
createdAt DateTime @default(now()) @map("created_at")

@@index([staffUserId])
@@index([ticketEntitlementId])
Expand Down
1 change: 1 addition & 0 deletions src/app/api/auth/ticket/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ async function redeem(
data: {
tokenDigest: issued.database.tokenDigest,
displayName,
displayNameConfirmedAt: now,
ticketEntitlementId: entitlement.id,
...(account ? {
accountIssuer: account.issuer,
Expand Down
Loading
Loading