diff --git a/apps/api/src/auth/auth.e2e-spec.ts b/apps/api/src/auth/auth.e2e-spec.ts index 08aaef7..b8b615c 100644 --- a/apps/api/src/auth/auth.e2e-spec.ts +++ b/apps/api/src/auth/auth.e2e-spec.ts @@ -20,6 +20,7 @@ import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js import { AppModule } from "../app.module.js"; import { configureApp } from "../main.js"; import { RedisService } from "../redis/redis.service.js"; +import { TokenService } from "./token.service.js"; process.env.NODE_ENV = "test"; process.env.WEB_ORIGIN = "http://localhost:3000"; @@ -59,8 +60,10 @@ describe("auth real PostgreSQL/Redis", () => { for (const key of await redis.keys("drift:auth:e2e:*")) await redis.del(key); delete process.env.SMS_SEND_LIMIT; + delete process.env.SMS_SEND_DEVICE_LIMIT; delete process.env.SMS_SEND_IP_LIMIT; delete process.env.SMS_VERIFY_LIMIT; + delete process.env.SMS_VERIFY_COMBINED_LIMIT; delete process.env.SMS_CODE_TTL_SECONDS; delete process.env.ACCESS_TOKEN_TTL_SECONDS; delete process.env.TRUST_PROXY; @@ -81,6 +84,8 @@ describe("auth real PostgreSQL/Redis", () => { .post("/api/v1/auth/sms/login") .send({ phone, deviceId, code: sent.body.data.debugCode }); } + const refreshValue = (cookie: string) => + cookie.slice("refresh_token=".length); it("sends, logs in with one-time code, stores encrypted identity, sets strict cookie, and guards me", async () => { const sent = await send(); expect(sent.status).toBe(201); @@ -126,6 +131,38 @@ describe("auth real PostgreSQL/Redis", () => { const keys = await redis.keys("drift:auth:e2e:limit:send-phone:*"); expect(await redis.ttl(keys[0]!)).toBeGreaterThan(0); }); + it("does not consume other send dimensions when one atomic limit is exceeded", async () => { + process.env.SMS_SEND_LIMIT = "1"; + process.env.SMS_SEND_DEVICE_LIMIT = "10"; + process.env.SMS_SEND_IP_LIMIT = "10"; + await send(); + const deviceKey = ( + await redis.keys("drift:auth:e2e:limit:send-device:*") + )[0]!; + const ipKey = (await redis.keys("drift:auth:e2e:limit:send-ip:*"))[0]!; + expect(await redis.mGet([deviceKey, ipKey])).toEqual(["1", "1"]); + await send().then((response) => expect(response.status).toBe(429)); + expect(await redis.mGet([deviceKey, ipKey])).toEqual(["1", "1"]); + }); + + it("does not consume combined verify dimension when phone verification is exceeded", async () => { + process.env.SMS_VERIFY_LIMIT = "1"; + process.env.SMS_VERIFY_COMBINED_LIMIT = "10"; + const sent = await send(); + const wrong = sent.body.data.debugCode === "000000" ? "999999" : "000000"; + const attempt = () => + request(app.getHttpServer()) + .post("/api/v1/auth/sms/login") + .send({ phone, deviceId, code: wrong }); + await attempt().then((response) => expect(response.status).toBe(401)); + const key = ( + await redis.keys("drift:auth:e2e:limit:verify-combined:*") + )[0]!; + expect(await redis.get(key)).toBe("1"); + await attempt().then((response) => expect(response.status).toBe(429)); + expect(await redis.get(key)).toBe("1"); + }); + it("omits debugCode in production", async () => { process.env.NODE_ENV = "production"; try { @@ -162,6 +199,45 @@ describe("auth real PostgreSQL/Redis", () => { .expect(401); expect((await prisma.session.findFirstOrThrow()).revokedAt).not.toBeNull(); }); + it("issues a 32-byte opaque refresh token that does not expose the session id", async () => { + const logged = await login(); + const token = refreshValue( + cookieValue(logged.headers["set-cookie"] as unknown as string[]), + ); + const session = await prisma.session.findFirstOrThrow(); + expect(Buffer.from(token, "base64url")).toHaveLength(32); + expect(token).not.toContain(session.id); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); + + it("detects first-generation replay after three rotations and revokes current token", async () => { + const logged = await login(); + const cookies = [ + cookieValue(logged.headers["set-cookie"] as unknown as string[]), + ]; + for (let generation = 0; generation < 3; generation += 1) { + const response = await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", cookies.at(-1)!) + .expect(200); + cookies.push( + cookieValue(response.headers["set-cookie"] as unknown as string[]), + ); + } + const replay = await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", cookies[0]!) + .expect(401); + expect(replay.body.code).toBe("AUTH_REFRESH_REUSED"); + await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", cookies.at(-1)!) + .expect(401); + }); + it("uses the first refresh cookie rather than allowing a duplicate to overwrite it", async () => { const logged = await login(); const valid = cookieValue( @@ -352,6 +428,51 @@ describe("auth real PostgreSQL/Redis", () => { .expect(401); }); + it.each([ + ["missing session", { session_id: randomUUID() }], + ["account claim mismatch", { sub: randomUUID() }], + ["device claim mismatch", { device_id: "another-device" }], + ])( + "guard independently rejects %s with a uniform error", + async (_name, change) => { + await login(); + const session = await prisma.session.findFirstOrThrow({ + include: { account: true }, + }); + const token = app.get(TokenService).issueAccess({ + sub: session.accountId, + session_id: session.id, + device_id: session.deviceId ?? "", + scopes: ["user"], + token_version: session.account.tokenVersion, + ...change, + }); + const denied = await request(app.getHttpServer()) + .get("/api/v1/me") + .set("Authorization", `Bearer ${token}`) + .expect(401); + expect(denied.body).toMatchObject({ + code: "AUTH_UNAUTHORIZED", + message: "Unauthorized", + }); + }, + ); + + it("guard independently rejects a database-expired session with a uniform error", async () => { + const logged = await login(); + await prisma.session.updateMany({ + data: { expiresAt: new Date(Date.now() - 1) }, + }); + const denied = await request(app.getHttpServer()) + .get("/api/v1/me") + .set("Authorization", `Bearer ${logged.body.data.accessToken}`) + .expect(401); + expect(denied.body).toMatchObject({ + code: "AUTH_UNAUTHORIZED", + message: "Unauthorized", + }); + }); + it("rejects a suspended account", async () => { const logged = await login(); const session = await prisma.session.findFirstOrThrow(); @@ -458,6 +579,54 @@ describe("auth real PostgreSQL/Redis", () => { ).toBeNull(); }); + it.each([ + "00000000-0000-0000-0000-000000000000", + "not-a-token%ZZ", + "x".repeat(4096), + ])( + "returns stable 401 for malformed refresh token %# without revoking", + async (token) => { + await login(); + const session = await prisma.session.findFirstOrThrow(); + const denied = await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", `refresh_token=${token}`) + .expect(401); + expect(denied.body.code).toBe("AUTH_INVALID_CREDENTIALS"); + expect( + (await prisma.session.findUniqueOrThrow({ where: { id: session.id } })) + .revokedAt, + ).toBeNull(); + }, + ); + + it("logs out a family through any used refresh token idempotently", async () => { + const logged = await login(); + const first = cookieValue( + logged.headers["set-cookie"] as unknown as string[], + ); + const rotated = await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", first) + .expect(200); + const current = cookieValue( + rotated.headers["set-cookie"] as unknown as string[], + ); + for (let n = 0; n < 2; n += 1) + await request(app.getHttpServer()) + .post("/api/v1/auth/logout") + .set("Origin", origin) + .set("Cookie", first) + .expect(200); + await request(app.getHttpServer()) + .post("/api/v1/auth/token/refresh") + .set("Origin", origin) + .set("Cookie", current) + .expect(401); + }); + it("recovers Auth Redis after a connection deadline and closes its sockets", async () => { const sockets = new Set(); const server = createServer((socket) => { diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 5c9d329..27e6894 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -11,7 +11,6 @@ import { envInt, phoneHmac, refreshHmac, - safeEqual, } from "./auth.config.js"; import { TokenService } from "./token.service.js"; @@ -45,19 +44,31 @@ export class AuthService { private prefix() { return process.env.REDIS_KEY_PREFIX ?? "drift:auth:"; } - private async limit( - kind: string, - parts: string[], - max: number, - ttl: number, + private async limitAll( + dimensions: Array<{ + kind: string; + parts: string[]; + max: number; + ttl: number; + }>, ): Promise { await this.redis.ensureConnected(); - const key = `${this.prefix()}limit:${kind}:${parts.join(":")}`; - const result = (await this.redis.client.eval( - `local n=redis.call('INCR',KEYS[1]); if n==1 then redis.call('EXPIRE',KEYS[1],ARGV[1]) end; return n`, - { keys: [key], arguments: [String(ttl)] }, - )) as number; - if (result > max) + const keys = dimensions.map( + ({ kind, parts }) => `${this.prefix()}limit:${kind}:${parts.join(":")}`, + ); + const args = dimensions.flatMap(({ max, ttl }) => [ + String(max), + String(ttl), + ]); + // Atomic across all dimensions on the MVP's single Redis node. Redis Cluster + // would require a colocated hash tag or a different distributed limiter. + const result = Number( + await this.redis.client.eval( + `for i,key in ipairs(KEYS) do local n=tonumber(redis.call('GET',key) or '0'); local max=tonumber(ARGV[(i-1)*2+1]); if n+1>max then return 0 end end; for i,key in ipairs(KEYS) do local n=redis.call('INCR',key); if n==1 then redis.call('EXPIRE',key,ARGV[(i-1)*2+2]) end end; return 1`, + { keys, arguments: args }, + ), + ); + if (result !== 1) throw new DomainException( ErrorCode.RATE_LIMITED, "Too many requests", @@ -71,25 +82,26 @@ export class AuthService { ): Promise<{ sent: true; debugCode?: string }> { const phone = this.normalizePhone(rawPhone); const digest = phoneHmac(phone); - await Promise.all([ - this.limit( - "send-phone", - [digest], - envInt("SMS_SEND_LIMIT", 3), - envInt("SMS_RATE_WINDOW_SECONDS", 300), - ), - this.limit( - "send-device", - [deviceId], - envInt("SMS_SEND_DEVICE_LIMIT", 5), - envInt("SMS_RATE_WINDOW_SECONDS", 300), - ), - this.limit( - "send-ip", - [phoneHmac(ip)], - envInt("SMS_SEND_IP_LIMIT", 10), - envInt("SMS_RATE_WINDOW_SECONDS", 300), - ), + const window = envInt("SMS_RATE_WINDOW_SECONDS", 300); + await this.limitAll([ + { + kind: "send-phone", + parts: [digest], + max: envInt("SMS_SEND_LIMIT", 3), + ttl: window, + }, + { + kind: "send-device", + parts: [deviceId], + max: envInt("SMS_SEND_DEVICE_LIMIT", 5), + ttl: window, + }, + { + kind: "send-ip", + parts: [phoneHmac(ip)], + max: envInt("SMS_SEND_IP_LIMIT", 10), + ttl: window, + }, ]); const code = randomInt(0, 1000000).toString().padStart(6, "0"); await this.redis.client.set( @@ -109,19 +121,20 @@ export class AuthService { ): Promise { const phone = this.normalizePhone(rawPhone); const digest = phoneHmac(phone); - await Promise.all([ - this.limit( - "verify-phone", - [digest], - envInt("SMS_VERIFY_LIMIT", 5), - envInt("SMS_RATE_WINDOW_SECONDS", 300), - ), - this.limit( - "verify-combined", - [digest, phoneHmac(ip), deviceId], - envInt("SMS_VERIFY_COMBINED_LIMIT", 5), - envInt("SMS_RATE_WINDOW_SECONDS", 300), - ), + const window = envInt("SMS_RATE_WINDOW_SECONDS", 300); + await this.limitAll([ + { + kind: "verify-phone", + parts: [digest], + max: envInt("SMS_VERIFY_LIMIT", 5), + ttl: window, + }, + { + kind: "verify-combined", + parts: [digest, phoneHmac(ip), deviceId], + max: envInt("SMS_VERIFY_COMBINED_LIMIT", 5), + ttl: window, + }, ]); const key = `${this.prefix()}code:${digest}`; const consumed = await this.redis.client.eval( @@ -162,7 +175,7 @@ export class AuthService { deviceId: string, ): Promise { const id = randomUUID(); - const refresh = this.tokens.newRefresh(id); + const refresh = this.tokens.newRefresh(); const expiresAt = new Date( Date.now() + envInt("REFRESH_TOKEN_TTL_SECONDS", 2592000) * 1000, ); @@ -174,6 +187,9 @@ export class AuthService { refreshTokenHash: refreshHmac(refresh), tokenFamily: id, expiresAt, + refreshTokens: { + create: { tokenHash: refreshHmac(refresh), generation: 0, expiresAt }, + }, }, }); return this.pair(account, id, deviceId, refresh); @@ -198,48 +214,59 @@ export class AuthService { }; } async refresh(token: string): Promise { - const parsed = this.parseRefresh(token); + if (!this.validRefreshShape(token)) throw this.invalid(); const hash = refreshHmac(token); - const observed = await this.prisma.session.findUnique({ - where: { id: parsed }, + const observed = await this.prisma.refreshToken.findUnique({ + where: { tokenHash: hash }, + include: { session: true }, }); - if ( - observed?.previousTokenHash && - safeEqual(observed.previousTokenHash, hash) - ) - return this.revokeReusedFamily(observed.tokenFamily); + if (!observed) throw this.invalid(); + if (observed.status === "USED") + return this.revokeReusedFamily(observed.session.tokenFamily); try { const result = await this.prisma.$transaction( async (tx): Promise => { - const session = await tx.session.findUnique({ - where: { id: parsed }, - include: { account: true }, + const stored = await tx.refreshToken.findUnique({ + where: { tokenHash: hash }, + include: { session: { include: { account: true } } }, }); - if (!session) throw this.invalid(); + if (!stored) throw this.invalid(); + if (stored.status === "USED") + return { reusedFamily: stored.session.tokenFamily }; + const session = stored.session; if ( - session.previousTokenHash && - safeEqual(session.previousTokenHash, hash) - ) - return { reusedFamily: session.tokenFamily }; - if ( - !safeEqual(session.refreshTokenHash, hash) || session.revokedAt || session.expiresAt <= new Date() || + stored.expiresAt <= new Date() || session.account.status !== "ACTIVE" ) throw this.invalid(); - const next = this.tokens.newRefresh(session.id); - const changed = await tx.session.updateMany({ - where: { id: session.id, refreshTokenHash: hash, revokedAt: null }, - data: { - previousTokenHash: hash, - refreshTokenHash: refreshHmac(next), - generation: { increment: 1 }, - rotatedAt: new Date(), - }, + const next = this.tokens.newRefresh(); + const nextHash = refreshHmac(next); + const usedAt = new Date(); + const changed = await tx.refreshToken.updateMany({ + where: { id: stored.id, status: "ACTIVE" }, + data: { status: "USED", usedAt, replacedByHash: nextHash }, }); if (changed.count !== 1) return { reusedFamily: session.tokenFamily }; + await tx.refreshToken.create({ + data: { + tokenHash: nextHash, + sessionId: session.id, + generation: stored.generation + 1, + expiresAt: session.expiresAt, + }, + }); + await tx.session.update({ + where: { id: session.id }, + data: { + previousTokenHash: hash, + refreshTokenHash: nextHash, + generation: stored.generation + 1, + rotatedAt: usedAt, + }, + }); return this.pair( session.account, session.id, @@ -256,16 +283,21 @@ export class AuthService { if (error instanceof DomainException) throw error; if ( error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2034" + (error.code === "P2034" || error.code === "P2002") ) { - const session = await this.prisma.session.findUnique({ - where: { id: parsed }, - }); - if ( - session?.previousTokenHash && - safeEqual(session.previousTokenHash, hash) - ) - return this.revokeReusedFamily(session.tokenFamily); + // A serialization/uniqueness loser can observe the winner only after its + // transaction commits. Poll briefly, then revoke outside the failed tx. + for (let attempt = 0; attempt < 5; attempt += 1) { + const raced = await this.prisma.refreshToken.findUnique({ + where: { tokenHash: hash }, + include: { session: true }, + }); + if (raced?.status === "USED") + return this.revokeReusedFamily(raced.session.tokenFamily); + await new Promise((resolve) => + setTimeout(resolve, 10 * (attempt + 1)), + ); + } } throw error; } @@ -282,26 +314,19 @@ export class AuthService { ); } async logout(token: string | undefined): Promise { - if (!token) return; - let id: string; - try { - id = this.parseRefresh(token); - } catch { - return; - } - const hash = refreshHmac(token); + if (!token || !this.validRefreshShape(token)) return; + const stored = await this.prisma.refreshToken.findUnique({ + where: { tokenHash: refreshHmac(token) }, + include: { session: true }, + }); + if (!stored) return; await this.prisma.session.updateMany({ - where: { - id, - OR: [{ refreshTokenHash: hash }, { previousTokenHash: hash }], - }, + where: { tokenFamily: stored.session.tokenFamily }, data: { revokedAt: new Date() }, }); } - private parseRefresh(token: string): string { - const id = token.split(".")[0]; - if (!id || !/^[0-9a-f-]{36}$/.test(id)) throw this.invalid(); - return id; + private validRefreshShape(token: string): boolean { + return /^[A-Za-z0-9_-]{43}$/.test(token); } private invalid() { return new DomainException( diff --git a/apps/api/src/auth/token.service.ts b/apps/api/src/auth/token.service.ts index 5554617..4d2cc1d 100644 --- a/apps/api/src/auth/token.service.ts +++ b/apps/api/src/auth/token.service.ts @@ -76,7 +76,7 @@ export class TokenService { ); } } - newRefresh(sessionId: string): string { - return `${sessionId}.${randomBytes(32).toString("base64url")}`; + newRefresh(): string { + return randomBytes(32).toString("base64url"); } } diff --git a/prisma/migrations/0003_refresh_token_history/migration.sql b/prisma/migrations/0003_refresh_token_history/migration.sql new file mode 100644 index 0000000..a994d4e --- /dev/null +++ b/prisma/migrations/0003_refresh_token_history/migration.sql @@ -0,0 +1,31 @@ +CREATE TYPE "RefreshTokenStatus" AS ENUM ('ACTIVE', 'USED'); + +CREATE TABLE "refresh_tokens" ( + "id" UUID NOT NULL, + "token_hash" VARCHAR(255) NOT NULL, + "session_id" UUID NOT NULL, + "generation" INTEGER NOT NULL, + "status" "RefreshTokenStatus" NOT NULL DEFAULT 'ACTIVE', + "used_at" TIMESTAMPTZ(3), + "replaced_by_hash" VARCHAR(255), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id"), + CONSTRAINT "refresh_tokens_generation_nonnegative" CHECK ("generation" >= 0), + CONSTRAINT "refresh_tokens_used_state_consistent" CHECK ( + ("status" = 'ACTIVE' AND "used_at" IS NULL AND "replaced_by_hash" IS NULL) + OR ("status" = 'USED' AND "used_at" IS NOT NULL AND "replaced_by_hash" IS NOT NULL) + ) +); + +-- Legacy refresh values exposed the session id and cannot be upgraded client-side. +-- Revoke those families; subsequent logins create opaque token history rows. +UPDATE "sessions" SET "revoked_at" = COALESCE("revoked_at", CURRENT_TIMESTAMP); + +CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash"); +CREATE UNIQUE INDEX "refresh_tokens_replaced_by_hash_key" ON "refresh_tokens"("replaced_by_hash"); +CREATE UNIQUE INDEX "refresh_tokens_session_id_generation_key" ON "refresh_tokens"("session_id", "generation"); +CREATE INDEX "refresh_tokens_session_id_status_idx" ON "refresh_tokens"("session_id", "status"); +CREATE INDEX "refresh_tokens_expires_at_idx" ON "refresh_tokens"("expires_at"); +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_session_id_fkey" + FOREIGN KEY ("session_id") REFERENCES "sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 324e009..04d0993 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,11 @@ enum AuthProvider { WECHAT } +enum RefreshTokenStatus { + ACTIVE + USED +} + enum ReviewStatus { DRAFT REVIEWING @@ -148,25 +153,44 @@ model AuthIdentity { } model Session { - id String @id @default(uuid()) @db.Uuid - accountId String @map("account_id") @db.Uuid - refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255) - deviceId String? @map("device_id") @db.VarChar(255) - expiresAt DateTime @map("expires_at") @db.Timestamptz(3) - revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) - tokenFamily String @map("token_family") @db.Uuid - generation Int @default(0) - previousTokenHash String? @unique @map("previous_token_hash") @db.VarChar(255) - rotatedAt DateTime? @map("rotated_at") @db.Timestamptz(3) - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) - updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) - account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") @db.Uuid + refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255) + deviceId String? @map("device_id") @db.VarChar(255) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + tokenFamily String @map("token_family") @db.Uuid + generation Int @default(0) + previousTokenHash String? @unique @map("previous_token_hash") @db.VarChar(255) + rotatedAt DateTime? @map("rotated_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + refreshTokens RefreshToken[] @@index([accountId, expiresAt]) @@index([tokenFamily]) @@map("sessions") } +model RefreshToken { + id String @id @default(uuid()) @db.Uuid + tokenHash String @unique @map("token_hash") @db.VarChar(255) + sessionId String @map("session_id") @db.Uuid + generation Int + status RefreshTokenStatus @default(ACTIVE) + usedAt DateTime? @map("used_at") @db.Timestamptz(3) + replacedByHash String? @unique @map("replaced_by_hash") @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@unique([sessionId, generation]) + @@index([sessionId, status]) + @@index([expiresAt]) + @@map("refresh_tokens") +} + model Bottle { id String @id @default(uuid()) @db.Uuid authorId String @map("author_id") @db.Uuid diff --git a/tests/integration/database.spec.ts b/tests/integration/database.spec.ts index 74f699d..13e9a8d 100644 --- a/tests/integration/database.spec.ts +++ b/tests/integration/database.spec.ts @@ -61,6 +61,38 @@ describe("database authority constraints", () => { }); afterAll(async () => prisma.$disconnect()); + it("defines indexed refresh-token history", async () => { + const columns = await prisma.$queryRaw>` + SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'refresh_tokens'`; + expect(columns.map(({ column_name }) => column_name)).toEqual( + expect.arrayContaining([ + "token_hash", + "session_id", + "generation", + "status", + "used_at", + "replaced_by_hash", + "created_at", + "expires_at", + ]), + ); + const indexes = await prisma.$queryRaw>` + SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'refresh_tokens'`; + expect( + indexes.some( + ({ indexdef }) => + indexdef.includes("UNIQUE") && indexdef.includes("token_hash"), + ), + ).toBe(true); + expect( + indexes.some( + ({ indexdef }) => + indexdef.includes("session_id") && indexdef.includes("generation"), + ), + ).toBe(true); + }); + it("expresses independent moderation and pool lifecycle state", async () => { const author = await createAccount("states"); const bottle = await prisma.bottle.create({