|
1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | +import { PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans"; |
2 | 3 | import { wait } from "@stackframe/stack-shared/dist/utils/promises"; |
3 | 4 | import { it } from "../../../../helpers"; |
4 | | -import { Auth, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../backend-helpers"; |
| 5 | +import { Auth, InternalProjectKeys, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../backend-helpers"; |
5 | 6 |
|
6 | 7 | async function uploadBatch(options: { |
7 | 8 | browserSessionId: string, |
@@ -1382,3 +1383,134 @@ it("admin list session replays rejects invalid filter parameters", async ({ expe |
1382 | 1383 | } |
1383 | 1384 | `); |
1384 | 1385 | }); |
| 1386 | + |
| 1387 | +// ============================================================================ |
| 1388 | +// Session replay limit enforcement tests |
| 1389 | +// ============================================================================ |
| 1390 | + |
| 1391 | +async function withInternalProject<T>(fn: () => Promise<T>): Promise<T> { |
| 1392 | + const savedKeys = backendContext.value.projectKeys; |
| 1393 | + const savedUserAuth = backendContext.value.userAuth; |
| 1394 | + backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null }); |
| 1395 | + try { |
| 1396 | + return await fn(); |
| 1397 | + } finally { |
| 1398 | + backendContext.set({ projectKeys: savedKeys, userAuth: savedUserAuth }); |
| 1399 | + } |
| 1400 | +} |
| 1401 | + |
| 1402 | +async function getSessionReplayItemQuantity(ownerTeamId: string) { |
| 1403 | + return await withInternalProject(async () => { |
| 1404 | + const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/session_replays`, { |
| 1405 | + accessType: "server", |
| 1406 | + }); |
| 1407 | + if (response.status !== 200) { |
| 1408 | + throw new Error(`Failed to get session_replays item: ${JSON.stringify(response.body)}`); |
| 1409 | + } |
| 1410 | + return response.body.quantity as number; |
| 1411 | + }); |
| 1412 | +} |
| 1413 | + |
| 1414 | +async function setSessionReplayItemQuantity(ownerTeamId: string, quantity: number) { |
| 1415 | + const currentQuantity = await getSessionReplayItemQuantity(ownerTeamId); |
| 1416 | + const delta = quantity - currentQuantity; |
| 1417 | + |
| 1418 | + await withInternalProject(async () => { |
| 1419 | + const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/session_replays/update-quantity?allow_negative=true`, { |
| 1420 | + method: "POST", |
| 1421 | + accessType: "server", |
| 1422 | + body: { delta }, |
| 1423 | + }); |
| 1424 | + if (response.status !== 200) { |
| 1425 | + throw new Error(`Failed to set session_replays quantity: ${JSON.stringify(response.body)}`); |
| 1426 | + } |
| 1427 | + }); |
| 1428 | +} |
| 1429 | + |
| 1430 | +it("free plan starts with correct session replay allocation", async ({ expect }) => { |
| 1431 | + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); |
| 1432 | + const ownerTeamId = createProjectResponse.body.owner_team_id; |
| 1433 | + |
| 1434 | + const quantity = await getSessionReplayItemQuantity(ownerTeamId); |
| 1435 | + expect(quantity).toBe(PLAN_LIMITS.free.sessionReplays); |
| 1436 | +}); |
| 1437 | + |
| 1438 | +it("rejects new session replay when quota is exhausted", async ({ expect }) => { |
| 1439 | + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); |
| 1440 | + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); |
| 1441 | + const ownerTeamId = createProjectResponse.body.owner_team_id; |
| 1442 | + |
| 1443 | + await Auth.Otp.signIn(); |
| 1444 | + await setSessionReplayItemQuantity(ownerTeamId, 0); |
| 1445 | + |
| 1446 | + const now = Date.now(); |
| 1447 | + const res = await uploadBatch({ |
| 1448 | + browserSessionId: randomUUID(), |
| 1449 | + batchId: randomUUID(), |
| 1450 | + startedAtMs: now, |
| 1451 | + sentAtMs: now + 500, |
| 1452 | + events: [{ type: 2, timestamp: now + 100 }], |
| 1453 | + }); |
| 1454 | + |
| 1455 | + expect(res.status).toBe(400); |
| 1456 | + expect(res.body.code).toBe("ITEM_QUANTITY_INSUFFICIENT_AMOUNT"); |
| 1457 | +}); |
| 1458 | + |
| 1459 | +it("accepts new session replay and debits quota by 1", async ({ expect }) => { |
| 1460 | + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); |
| 1461 | + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); |
| 1462 | + const ownerTeamId = createProjectResponse.body.owner_team_id; |
| 1463 | + |
| 1464 | + await Auth.Otp.signIn(); |
| 1465 | + |
| 1466 | + const quantityBefore = await getSessionReplayItemQuantity(ownerTeamId); |
| 1467 | + |
| 1468 | + const now = Date.now(); |
| 1469 | + const res = await uploadBatch({ |
| 1470 | + browserSessionId: randomUUID(), |
| 1471 | + batchId: randomUUID(), |
| 1472 | + startedAtMs: now, |
| 1473 | + sentAtMs: now + 500, |
| 1474 | + events: [{ type: 2, timestamp: now + 100 }], |
| 1475 | + }); |
| 1476 | + |
| 1477 | + expect(res.status).toBe(200); |
| 1478 | + expect(res.body.deduped).toBe(false); |
| 1479 | + |
| 1480 | + const quantityAfter = await getSessionReplayItemQuantity(ownerTeamId); |
| 1481 | + expect(quantityAfter).toBe(quantityBefore - 1); |
| 1482 | +}); |
| 1483 | + |
| 1484 | +it("does not debit quota when appending chunks to an existing session replay", async ({ expect }) => { |
| 1485 | + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); |
| 1486 | + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); |
| 1487 | + const ownerTeamId = createProjectResponse.body.owner_team_id; |
| 1488 | + |
| 1489 | + await Auth.Otp.signIn(); |
| 1490 | + |
| 1491 | + const now = Date.now(); |
| 1492 | + const firstBatch = await uploadBatch({ |
| 1493 | + browserSessionId: randomUUID(), |
| 1494 | + batchId: randomUUID(), |
| 1495 | + startedAtMs: now, |
| 1496 | + sentAtMs: now + 500, |
| 1497 | + events: [{ type: 2, timestamp: now + 100 }], |
| 1498 | + }); |
| 1499 | + expect(firstBatch.status).toBe(200); |
| 1500 | + expect(firstBatch.body.deduped).toBe(false); |
| 1501 | + |
| 1502 | + const quantityAfterFirst = await getSessionReplayItemQuantity(ownerTeamId); |
| 1503 | + |
| 1504 | + const secondBatch = await uploadBatch({ |
| 1505 | + browserSessionId: randomUUID(), |
| 1506 | + batchId: randomUUID(), |
| 1507 | + startedAtMs: now, |
| 1508 | + sentAtMs: now + 1000, |
| 1509 | + events: [{ type: 3, timestamp: now + 500 }], |
| 1510 | + }); |
| 1511 | + expect(secondBatch.status).toBe(200); |
| 1512 | + expect(secondBatch.body.session_replay_id).toBe(firstBatch.body.session_replay_id); |
| 1513 | + |
| 1514 | + const quantityAfterSecond = await getSessionReplayItemQuantity(ownerTeamId); |
| 1515 | + expect(quantityAfterSecond).toBe(quantityAfterFirst); |
| 1516 | +}); |
0 commit comments