286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */
|
|
import "reflect-metadata";
|
|
import { type INestApplication } from "@nestjs/common";
|
|
import { Test } from "@nestjs/testing";
|
|
import { PrismaClient } from "@prisma/client";
|
|
import { randomUUID } from "node:crypto";
|
|
import request from "supertest";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js";
|
|
import { AppModule } from "../app.module.js";
|
|
import { TokenService } from "../auth/token.service.js";
|
|
import { configureApp } from "../main.js";
|
|
import { utc8UsageDate } from "./usage-date.js";
|
|
|
|
process.env.NODE_ENV = "test";
|
|
process.env.WEB_ORIGIN = "http://localhost:3000";
|
|
process.env.PHONE_ENCRYPTION_KEY =
|
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
|
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
|
process.env.VERIFICATION_CODE_HMAC_KEY =
|
|
"test-code-hmac-key-with-at-least-32-bytes";
|
|
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
|
"test-refresh-hmac-key-with-at-least-32-bytes";
|
|
const prisma = new PrismaClient();
|
|
|
|
describe("bottles with real PostgreSQL", () => {
|
|
let app: INestApplication;
|
|
let accountId: string;
|
|
let authorization: string;
|
|
|
|
beforeAll(async () => {
|
|
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
|
|
await prisma.$connect();
|
|
const module = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
app = module.createNestApplication();
|
|
configureApp(app);
|
|
await app.init();
|
|
});
|
|
beforeEach(async () => {
|
|
await prisma.outboxEvent.deleteMany();
|
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
|
|
({ accountId, authorization } = await actor("owner-device"));
|
|
});
|
|
afterAll(async () => {
|
|
await app?.close();
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
async function actor(deviceId: string) {
|
|
const account = await prisma.account.create({
|
|
data: { phoneCiphertext: Buffer.from("cipher"), phoneHmac: randomUUID() },
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
accountId: account.id,
|
|
refreshTokenHash: randomUUID(),
|
|
deviceId,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
tokenFamily: randomUUID(),
|
|
},
|
|
});
|
|
const token = app.get(TokenService).issueAccess({
|
|
sub: account.id,
|
|
session_id: session.id,
|
|
device_id: deviceId,
|
|
scopes: ["user"],
|
|
token_version: 0,
|
|
});
|
|
return { accountId: account.id, authorization: `Bearer ${token}` };
|
|
}
|
|
const create = (key: string, contentText = "hello sea") =>
|
|
request(app.getHttpServer())
|
|
.post("/api/v1/bottles")
|
|
.set("Authorization", authorization)
|
|
.set("Idempotency-Key", key)
|
|
.send({ contentText });
|
|
|
|
it("creates reviewing/closed bottle, task, and content-free outbox atomically", async () => {
|
|
const response = await create(randomUUID(), " hello sea ").expect(201);
|
|
expect(response.body.data).toMatchObject({
|
|
contentText: "hello sea",
|
|
reviewStatus: "REVIEWING",
|
|
poolStatus: "CLOSED",
|
|
});
|
|
const bottle = await prisma.bottle.findUniqueOrThrow({
|
|
where: { id: response.body.data.id },
|
|
});
|
|
const [task, event, usage] = await Promise.all([
|
|
prisma.moderationTask.findFirstOrThrow({
|
|
where: { targetId: bottle.id },
|
|
}),
|
|
prisma.outboxEvent.findFirstOrThrow({
|
|
where: { aggregateId: bottle.id },
|
|
}),
|
|
prisma.dailyUsage.findFirstOrThrow({ where: { accountId } }),
|
|
]);
|
|
expect(task.payloadHash).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(event.payload).toEqual({ bottleId: bottle.id, taskId: task.id });
|
|
expect(JSON.stringify(event.payload)).not.toContain("hello sea");
|
|
expect(usage.bottlesCreated).toBe(1);
|
|
});
|
|
|
|
it("deduplicates retry without incrementing quota or creating events", async () => {
|
|
const key = randomUUID();
|
|
const first = await create(key).expect(201);
|
|
const retry = await create(key).expect(201);
|
|
expect(retry.body.data.id).toBe(first.body.data.id);
|
|
expect(await prisma.bottle.count()).toBe(1);
|
|
expect(await prisma.moderationTask.count()).toBe(1);
|
|
expect(await prisma.outboxEvent.count()).toBe(1);
|
|
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(1);
|
|
});
|
|
|
|
it("deduplicates concurrent retries without incrementing quota", async () => {
|
|
const key = randomUUID();
|
|
const results = await Promise.all(
|
|
Array.from({ length: 8 }, () => create(key, "same content")),
|
|
);
|
|
expect(results.every((result) => result.status === 201)).toBe(true);
|
|
expect(
|
|
new Set(results.map((result) => (result.body.data as { id: string }).id)),
|
|
).toHaveLength(1);
|
|
expect(await prisma.bottle.count()).toBe(1);
|
|
expect(await prisma.moderationTask.count()).toBe(1);
|
|
expect(await prisma.outboxEvent.count()).toBe(1);
|
|
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(1);
|
|
});
|
|
|
|
it("atomically permits exactly ten of twelve concurrent requests", async () => {
|
|
const results = await Promise.all(
|
|
Array.from({ length: 12 }, (_, index) =>
|
|
create(randomUUID(), `b${index}`),
|
|
),
|
|
);
|
|
expect(results.filter((x) => x.status === 201)).toHaveLength(10);
|
|
const denied = results.filter((x) => x.status === 429);
|
|
expect(denied).toHaveLength(2);
|
|
expect(denied.every((x) => x.body.code === "BOTTLE_DAILY_LIMIT")).toBe(
|
|
true,
|
|
);
|
|
expect(await prisma.bottle.count()).toBe(10);
|
|
expect(await prisma.outboxEvent.count()).toBe(10);
|
|
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesCreated).toBe(
|
|
10,
|
|
);
|
|
});
|
|
|
|
it("validates trimmed content and idempotency key", async () => {
|
|
await create(randomUUID(), " ").expect(400);
|
|
await create(randomUUID(), "x".repeat(1001)).expect(400);
|
|
await request(app.getHttpServer())
|
|
.post("/api/v1/bottles")
|
|
.set("Authorization", authorization)
|
|
.send({ contentText: "hello" })
|
|
.expect(400);
|
|
});
|
|
|
|
it("allows only owner reads and cursor-paginates own bottles", async () => {
|
|
const ids: string[] = [];
|
|
for (let n = 0; n < 3; n += 1)
|
|
ids.push(
|
|
(await create(randomUUID(), `message${n}`).expect(201)).body.data.id,
|
|
);
|
|
await request(app.getHttpServer())
|
|
.get(`/api/v1/bottles/${ids[0]}`)
|
|
.set("Authorization", authorization)
|
|
.expect(200);
|
|
const stranger = await actor("stranger-device");
|
|
await request(app.getHttpServer())
|
|
.get(`/api/v1/bottles/${ids[0]}`)
|
|
.set("Authorization", stranger.authorization)
|
|
.expect(404);
|
|
const page1 = await request(app.getHttpServer())
|
|
.get("/api/v1/me/bottles?limit=2")
|
|
.set("Authorization", authorization)
|
|
.expect(200);
|
|
expect(page1.body.data.items).toHaveLength(2);
|
|
expect(page1.body.data.nextCursor).toEqual(expect.any(String));
|
|
const page2 = await request(app.getHttpServer())
|
|
.get(`/api/v1/me/bottles?limit=2&cursor=${page1.body.data.nextCursor}`)
|
|
.set("Authorization", authorization)
|
|
.expect(200);
|
|
expect(page2.body.data.items).toHaveLength(1);
|
|
expect(page2.body.data.nextCursor).toBeNull();
|
|
});
|
|
|
|
it("returns 400 for malformed, missing, too-long, and foreign cursors", async () => {
|
|
await request(app.getHttpServer())
|
|
.get("/api/v1/me/bottles?cursor=not-a-uuid")
|
|
.set("Authorization", authorization)
|
|
.expect(400);
|
|
await request(app.getHttpServer())
|
|
.get(`/api/v1/me/bottles?cursor=${randomUUID()}`)
|
|
.set("Authorization", authorization)
|
|
.expect(400);
|
|
const stranger = await actor("cursor-stranger");
|
|
const foreignId = (
|
|
await request(app.getHttpServer())
|
|
.post("/api/v1/bottles")
|
|
.set("Authorization", stranger.authorization)
|
|
.set("Idempotency-Key", randomUUID())
|
|
.send({ contentText: "foreign" })
|
|
.expect(201)
|
|
).body.data.id;
|
|
await request(app.getHttpServer())
|
|
.get(`/api/v1/me/bottles?cursor=${foreignId}`)
|
|
.set("Authorization", authorization)
|
|
.expect(400);
|
|
});
|
|
|
|
it("rejects blank and overlong idempotency keys", async () => {
|
|
await create(" ").expect(400);
|
|
await create("x".repeat(129)).expect(400);
|
|
});
|
|
|
|
it.each(["SUSPENSION", "BAN"] as const)(
|
|
"blocks active %s sanction",
|
|
async (type) => {
|
|
await prisma.sanction.create({
|
|
data: {
|
|
accountId,
|
|
type,
|
|
reason: "policy",
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
const denied = await create(randomUUID()).expect(403);
|
|
expect(denied.body.code).toBe("ACCOUNT_SANCTIONED");
|
|
expect(await prisma.bottle.count()).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("blocks non-active accounts", async () => {
|
|
await prisma.account.update({
|
|
where: { id: accountId },
|
|
data: { status: "SUSPENDED" },
|
|
});
|
|
await create(randomUUID()).expect(401);
|
|
expect(await prisma.bottle.count()).toBe(0);
|
|
});
|
|
|
|
it("blocks sanctions that have started but ignores future sanctions", async () => {
|
|
await prisma.sanction.create({
|
|
data: {
|
|
accountId,
|
|
type: "BAN",
|
|
reason: "future",
|
|
startsAt: new Date(Date.now() + 60_000),
|
|
},
|
|
});
|
|
await create(randomUUID()).expect(201);
|
|
});
|
|
|
|
it("ignores expired and revoked sanctions", async () => {
|
|
await prisma.sanction.createMany({
|
|
data: [
|
|
{
|
|
accountId,
|
|
type: "BAN",
|
|
reason: "expired",
|
|
expiresAt: new Date(Date.now() - 1),
|
|
},
|
|
{
|
|
accountId,
|
|
type: "SUSPENSION",
|
|
reason: "revoked",
|
|
revokedAt: new Date(),
|
|
},
|
|
],
|
|
});
|
|
await create(randomUUID()).expect(201);
|
|
});
|
|
|
|
it("computes usage day across UTC+8 midnight", () => {
|
|
expect(utc8UsageDate(new Date("2026-09-15T15:59:59.999Z"))).toBe(
|
|
"2026-09-15",
|
|
);
|
|
expect(utc8UsageDate(new Date("2026-09-15T16:00:00.000Z"))).toBe(
|
|
"2026-09-16",
|
|
);
|
|
});
|
|
});
|