Sign panel sessions and isolate test runs
Some checks failed
Deploy to Production / test (push) Failing after 29s
Some checks failed
Deploy to Production / test (push) Failing after 29s
- Replace in-memory auth sessions with signed cookies and signed OAuth state - Add auth route coverage and update panel/web server wiring - Switch test script to per-file Bun processes and clean up type checks
This commit is contained in:
@@ -133,13 +133,14 @@ The main variables you need in `.env` are:
|
|||||||
- `DISCORD_CLIENT_SECRET`
|
- `DISCORD_CLIENT_SECRET`
|
||||||
- `DISCORD_GUILD_ID`
|
- `DISCORD_GUILD_ID`
|
||||||
- `ADMIN_USER_IDS`
|
- `ADMIN_USER_IDS`
|
||||||
|
- `SESSION_SECRET`
|
||||||
- `DB_USER`
|
- `DB_USER`
|
||||||
- `DB_PASSWORD`
|
- `DB_PASSWORD`
|
||||||
- `DB_NAME`
|
- `DB_NAME`
|
||||||
- `DATABASE_URL`
|
- `DATABASE_URL`
|
||||||
- `PANEL_BASE_URL`
|
- `PANEL_BASE_URL`
|
||||||
|
|
||||||
Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`.
|
Players can authenticate into the panel only after they exist in the `users` table. Admin access is determined by `ADMIN_USER_IDS`, and panel sessions are stored in signed cookies keyed by `SESSION_SECRET`.
|
||||||
|
|
||||||
## API and panel summary
|
## API and panel summary
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Aurora's API is a Bun server that runs inside the same process as the Discord bo
|
|||||||
|
|
||||||
- Entry point: `api/src/server.ts`
|
- Entry point: `api/src/server.ts`
|
||||||
- Route dispatcher: `api/src/routes/index.ts`
|
- Route dispatcher: `api/src/routes/index.ts`
|
||||||
- Auth: Discord OAuth with in-memory session cookies
|
- Auth: Discord OAuth with signed session cookies
|
||||||
- WebSocket: `/ws`
|
- WebSocket: `/ws`
|
||||||
- Static assets: `/assets/*`
|
- Static assets: `/assets/*`
|
||||||
- Built panel fallback: `panel/dist`
|
- Built panel fallback: `panel/dist`
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
## Authentication and authorization
|
## Authentication and authorization
|
||||||
|
|
||||||
- OAuth routes live in `api/src/routes/auth.routes.ts`.
|
- OAuth routes live in `api/src/routes/auth.routes.ts`.
|
||||||
- Sessions are stored in memory and keyed by the `aurora_session` cookie.
|
- Sessions are stored in signed `aurora_session` cookies.
|
||||||
- Session TTL is 7 days.
|
- Session TTL is 7 days.
|
||||||
- Login succeeds only for users already present in the `users` table.
|
- Login succeeds only for users already present in the `users` table.
|
||||||
- Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`.
|
- Role is `admin` if the Discord ID is in `ADMIN_USER_IDS`, otherwise `player`.
|
||||||
@@ -62,7 +62,7 @@ Hard limits:
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Sessions and some caches are in-memory only and are lost on restart.
|
- Some runtime caches are in-memory only and are lost on restart.
|
||||||
- The server registers game plugins at startup; duplicate registration throws.
|
- The server registers game plugins at startup; duplicate registration throws.
|
||||||
- BigInt-safe JSON matters for nearly every domain route.
|
- BigInt-safe JSON matters for nearly every domain route.
|
||||||
- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin.
|
- The panel's auth flow depends on `PANEL_BASE_URL` matching the OAuth callback origin.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class GameServer {
|
|||||||
readonly roomManager = new RoomManager();
|
readonly roomManager = new RoomManager();
|
||||||
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
private connections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||||
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
private replacedConnections = new Map<string, ServerWebSocket<WsConnectionData>>();
|
||||||
private bunServer: Server | null = null;
|
private bunServer: Server<WsConnectionData> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Subscribe to room events and route them to the right clients
|
// Subscribe to room events and route them to the right clients
|
||||||
@@ -141,7 +141,7 @@ export class GameServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setServer(server: Server): void {
|
setServer(server: Server<WsConnectionData>): void {
|
||||||
this.bunServer = server;
|
this.bunServer = server;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
103
api/src/routes/auth.routes.test.ts
Normal file
103
api/src/routes/auth.routes.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||||
|
|
||||||
|
const findFirst = mock(async () => ({ id: 123n }));
|
||||||
|
|
||||||
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
|
DrizzleClient: {
|
||||||
|
query: {
|
||||||
|
users: {
|
||||||
|
findFirst,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@shared/lib/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
error: () => { },
|
||||||
|
info: () => { },
|
||||||
|
warn: () => { },
|
||||||
|
debug: () => { },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { authRoutes, getSession } from "./auth.routes";
|
||||||
|
|
||||||
|
describe("Auth Routes", () => {
|
||||||
|
let fetchSpy: ReturnType<typeof spyOn> | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.DISCORD_CLIENT_ID = "client-id";
|
||||||
|
process.env.DISCORD_CLIENT_SECRET = "client-secret";
|
||||||
|
process.env.SESSION_SECRET = "session-secret";
|
||||||
|
process.env.PANEL_BASE_URL = "http://localhost:3000";
|
||||||
|
process.env.ADMIN_USER_IDS = "123";
|
||||||
|
findFirst.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchSpy?.mockRestore();
|
||||||
|
fetchSpy = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a signed session cookie during OAuth callback", async () => {
|
||||||
|
const loginUrl = new URL("http://localhost/auth/discord?return_to=http://localhost:5173/admin");
|
||||||
|
const loginRes = await authRoutes.handler({
|
||||||
|
req: new Request(loginUrl, { method: "GET" }),
|
||||||
|
url: loginUrl,
|
||||||
|
method: "GET",
|
||||||
|
pathname: "/auth/discord",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loginRes?.status).toBe(302);
|
||||||
|
|
||||||
|
const redirectLocation = loginRes?.headers.get("Location");
|
||||||
|
expect(redirectLocation).not.toBeNull();
|
||||||
|
|
||||||
|
const state = new URL(redirectLocation!).searchParams.get("state");
|
||||||
|
expect(state).not.toBeNull();
|
||||||
|
|
||||||
|
fetchSpy = spyOn(globalThis, "fetch");
|
||||||
|
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "discord-token" }), { status: 200 }));
|
||||||
|
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: "123",
|
||||||
|
username: "aurora-admin",
|
||||||
|
avatar: null,
|
||||||
|
}), { status: 200 }));
|
||||||
|
|
||||||
|
const callbackUrl = new URL(`http://localhost/auth/callback?code=oauth-code&state=${encodeURIComponent(state!)}`);
|
||||||
|
const callbackRes = await authRoutes.handler({
|
||||||
|
req: new Request(callbackUrl, { method: "GET" }),
|
||||||
|
url: callbackUrl,
|
||||||
|
method: "GET",
|
||||||
|
pathname: "/auth/callback",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackRes?.status).toBe(302);
|
||||||
|
expect(callbackRes?.headers.get("Location")).toBe("/admin");
|
||||||
|
|
||||||
|
const setCookie = callbackRes?.headers.get("Set-Cookie");
|
||||||
|
expect(setCookie).toContain("aurora_session=");
|
||||||
|
|
||||||
|
const sessionCookie = setCookie!.split(";")[0]!;
|
||||||
|
const session = getSession(new Request("http://localhost/api/me", {
|
||||||
|
headers: { cookie: sessionCookie },
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(session).toEqual({
|
||||||
|
discordId: "123",
|
||||||
|
username: "aurora-admin",
|
||||||
|
avatar: null,
|
||||||
|
role: "admin",
|
||||||
|
expiresAt: expect.any(Number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tampered session cookies", () => {
|
||||||
|
const session = getSession(new Request("http://localhost/api/me", {
|
||||||
|
headers: { cookie: "aurora_session=not-a-valid-token" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(session).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
* Handles login flow, callback, logout, and session management.
|
* Handles login flow, callback, logout, and session management.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||||
import type { RouteContext, RouteModule } from "./types";
|
import type { RouteContext, RouteModule } from "./types";
|
||||||
import { jsonResponse, errorResponse } from "./utils";
|
import { jsonResponse, errorResponse } from "./utils";
|
||||||
import { logger } from "@shared/lib/logger";
|
import { logger } from "@shared/lib/logger";
|
||||||
@@ -10,7 +12,7 @@ import { DrizzleClient } from "@shared/db/DrizzleClient";
|
|||||||
import { users } from "@shared/db/schema";
|
import { users } from "@shared/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
// In-memory session store: token → { discordId, username, avatar, role, expiresAt }
|
// Signed session payload stored in the aurora_session cookie.
|
||||||
export interface Session {
|
export interface Session {
|
||||||
discordId: string;
|
discordId: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -19,10 +21,21 @@ export interface Session {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions = new Map<string, Session>();
|
interface SessionTokenPayload extends Session {
|
||||||
const redirects = new Map<string, string>(); // redirect token -> return_to URL
|
v: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OAuthStatePayload {
|
||||||
|
exp: number;
|
||||||
|
returnTo: string;
|
||||||
|
v: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COOKIE_NAME = "aurora_session";
|
||||||
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
const OAUTH_STATE_MAX_AGE = 10 * 60 * 1000; // 10 minutes
|
||||||
|
const TOKEN_NAMESPACE = "aurora.auth";
|
||||||
|
const TOKEN_VERSION = "v1";
|
||||||
|
|
||||||
function getEnv(key: string): string {
|
function getEnv(key: string): string {
|
||||||
const val = process.env[key];
|
const val = process.env[key];
|
||||||
@@ -30,15 +43,70 @@ function getEnv(key: string): string {
|
|||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSessionSecret(required: boolean = false): string | null {
|
||||||
|
const secret = process.env.SESSION_SECRET ?? process.env.DISCORD_CLIENT_SECRET ?? null;
|
||||||
|
if (!secret && required) {
|
||||||
|
throw new Error("Missing env: SESSION_SECRET or DISCORD_CLIENT_SECRET");
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireSessionSecret(): string {
|
||||||
|
return getSessionSecret(true)!;
|
||||||
|
}
|
||||||
|
|
||||||
function getAdminIds(): string[] {
|
function getAdminIds(): string[] {
|
||||||
const raw = process.env.ADMIN_USER_IDS ?? "";
|
const raw = process.env.ADMIN_USER_IDS ?? "";
|
||||||
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
return raw.split(",").map(s => s.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateToken(): string {
|
function encodeBase64Url(value: string): string {
|
||||||
const bytes = new Uint8Array(32);
|
return Buffer.from(value, "utf8").toString("base64url");
|
||||||
crypto.getRandomValues(bytes);
|
}
|
||||||
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
|
|
||||||
|
function decodeBase64Url(value: string): string {
|
||||||
|
return Buffer.from(value, "base64url").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function signValue(kind: string, encodedPayload: string, secret: string): string {
|
||||||
|
return createHmac("sha256", secret)
|
||||||
|
.update(`${TOKEN_NAMESPACE}.${kind}.${encodedPayload}`)
|
||||||
|
.digest("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeSignedToken(kind: string, payload: SessionTokenPayload | OAuthStatePayload, secret: string): string {
|
||||||
|
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
|
||||||
|
const signature = signValue(kind, encodedPayload, secret);
|
||||||
|
return `${TOKEN_VERSION}.${encodedPayload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSignedToken<T>(token: string | undefined, kind: string): T | null {
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const secret = getSessionSecret();
|
||||||
|
if (!secret) return null;
|
||||||
|
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const version = parts[0];
|
||||||
|
const encodedPayload = parts[1];
|
||||||
|
const providedSignature = parts[2];
|
||||||
|
if (version !== TOKEN_VERSION) return null;
|
||||||
|
if (!encodedPayload || !providedSignature) return null;
|
||||||
|
|
||||||
|
const expectedSignature = signValue(kind, encodedPayload, secret);
|
||||||
|
const providedBuffer = Buffer.from(providedSignature);
|
||||||
|
const expectedBuffer = Buffer.from(expectedSignature);
|
||||||
|
|
||||||
|
if (providedBuffer.length !== expectedBuffer.length) return null;
|
||||||
|
if (!timingSafeEqual(providedBuffer, expectedBuffer)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(decodeBase64Url(encodedPayload)) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBaseUrl(): string {
|
function getBaseUrl(): string {
|
||||||
@@ -55,18 +123,65 @@ function parseCookies(header: string | null): Record<string, string> {
|
|||||||
return cookies;
|
return cookies;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeReturnTo(rawReturnTo: string | null, baseUrl: string): string {
|
||||||
|
if (!rawReturnTo || rawReturnTo.length > 1024) return "/";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (rawReturnTo.startsWith("/") && !rawReturnTo.startsWith("//")) {
|
||||||
|
return rawReturnTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new URL(rawReturnTo, baseUrl);
|
||||||
|
const allowedBase = new URL(baseUrl);
|
||||||
|
const isLocalhostRedirect = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
||||||
|
|
||||||
|
if (parsed.origin === allowedBase.origin || isLocalhostRedirect) {
|
||||||
|
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCookieAttributes(maxAgeSeconds?: number): string {
|
||||||
|
const attrs = [
|
||||||
|
"Path=/",
|
||||||
|
"HttpOnly",
|
||||||
|
"SameSite=Lax",
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (new URL(getBaseUrl()).protocol === "https:") {
|
||||||
|
attrs.push("Secure");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid PANEL_BASE_URL here; handlers that need it will fail explicitly.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof maxAgeSeconds === "number") {
|
||||||
|
attrs.push(`Max-Age=${maxAgeSeconds}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
/** Get session from request cookie */
|
/** Get session from request cookie */
|
||||||
export function getSession(req: Request): Session | null {
|
export function getSession(req: Request): Session | null {
|
||||||
const cookies = parseCookies(req.headers.get("cookie"));
|
const cookies = parseCookies(req.headers.get("cookie"));
|
||||||
const token = cookies["aurora_session"];
|
const payload = parseSignedToken<SessionTokenPayload>(cookies[COOKIE_NAME], "session");
|
||||||
if (!token) return null;
|
|
||||||
const session = sessions.get(token);
|
if (!payload || payload.v !== 1) return null;
|
||||||
if (!session) return null;
|
if (Date.now() > payload.expiresAt) return null;
|
||||||
if (Date.now() > session.expiresAt) {
|
|
||||||
sessions.delete(token);
|
return {
|
||||||
return null;
|
discordId: payload.discordId,
|
||||||
}
|
username: payload.username,
|
||||||
return session;
|
avatar: payload.avatar,
|
||||||
|
role: payload.role,
|
||||||
|
expiresAt: payload.expiresAt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if request is authenticated as admin */
|
/** Check if request is authenticated as admin */
|
||||||
@@ -84,20 +199,22 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const baseUrl = getBaseUrl();
|
const baseUrl = getBaseUrl();
|
||||||
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
|
const redirectUri = encodeURIComponent(`${baseUrl}/auth/callback`);
|
||||||
const scope = "identify+email";
|
const scope = "identify+email";
|
||||||
|
const secret = requireSessionSecret();
|
||||||
|
|
||||||
// Store return_to URL if provided
|
// Store return_to URL in signed OAuth state
|
||||||
const returnTo = ctx.url.searchParams.get("return_to") || "/";
|
const returnTo = sanitizeReturnTo(ctx.url.searchParams.get("return_to"), baseUrl);
|
||||||
const redirectToken = generateToken();
|
const state = serializeSignedToken("oauth", {
|
||||||
redirects.set(redirectToken, returnTo);
|
exp: Date.now() + OAUTH_STATE_MAX_AGE,
|
||||||
|
returnTo,
|
||||||
|
v: 1,
|
||||||
|
}, secret);
|
||||||
|
|
||||||
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}`;
|
const url = `https://discord.com/oauth2/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&state=${encodeURIComponent(state)}`;
|
||||||
|
|
||||||
// Set a temporary cookie with the redirect token
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: url,
|
Location: url,
|
||||||
"Set-Cookie": `aurora_redirect=${redirectToken}; Path=/; Max-Age=600; SameSite=Lax`,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -116,8 +233,13 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
|
const clientSecret = getEnv("DISCORD_CLIENT_SECRET");
|
||||||
const baseUrl = getBaseUrl();
|
const baseUrl = getBaseUrl();
|
||||||
const redirectUri = `${baseUrl}/auth/callback`;
|
const redirectUri = `${baseUrl}/auth/callback`;
|
||||||
|
const secret = requireSessionSecret();
|
||||||
|
const statePayload = parseSignedToken<OAuthStatePayload>(ctx.url.searchParams.get("state") ?? undefined, "oauth");
|
||||||
|
|
||||||
|
if (!statePayload || statePayload.v !== 1 || Date.now() > statePayload.exp) {
|
||||||
|
return errorResponse("Invalid OAuth state", 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Exchange code for token
|
|
||||||
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
const tokenRes = await fetch("https://discord.com/api/oauth2/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
@@ -165,40 +287,24 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
const adminIds = getAdminIds();
|
const adminIds = getAdminIds();
|
||||||
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
|
const role: "admin" | "player" = adminIds.includes(user.id) ? "admin" : "player";
|
||||||
|
|
||||||
// Create session
|
// Create signed session cookie
|
||||||
const token = generateToken();
|
const sessionToken = serializeSignedToken("session", {
|
||||||
sessions.set(token, {
|
|
||||||
discordId: user.id,
|
discordId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
avatar: user.avatar,
|
avatar: user.avatar,
|
||||||
role,
|
role,
|
||||||
expiresAt: Date.now() + SESSION_MAX_AGE,
|
expiresAt: Date.now() + SESSION_MAX_AGE,
|
||||||
});
|
v: 1,
|
||||||
|
}, secret);
|
||||||
|
|
||||||
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
|
logger.info("auth", `Login: ${user.username} (${user.id}) as ${role}`);
|
||||||
|
|
||||||
// Get return_to URL from redirect token cookie
|
|
||||||
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
|
||||||
const redirectToken = cookies["aurora_redirect"];
|
|
||||||
let returnTo = redirectToken && redirects.get(redirectToken) ? redirects.get(redirectToken)! : "/";
|
|
||||||
if (redirectToken) redirects.delete(redirectToken);
|
|
||||||
|
|
||||||
// Only allow redirects to localhost or relative paths (prevent open redirect)
|
|
||||||
try {
|
|
||||||
const parsed = new URL(returnTo, baseUrl);
|
|
||||||
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
|
|
||||||
returnTo = "/";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
returnTo = "/";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to panel with session cookie
|
// Redirect to panel with session cookie
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
Location: returnTo,
|
Location: sanitizeReturnTo(statePayload.returnTo, baseUrl),
|
||||||
"Set-Cookie": `aurora_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE / 1000}`,
|
"Set-Cookie": `${COOKIE_NAME}=${sessionToken}; ${buildCookieAttributes(SESSION_MAX_AGE / 1000)}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -209,14 +315,10 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
|
|||||||
|
|
||||||
// POST /auth/logout — clear session
|
// POST /auth/logout — clear session
|
||||||
if (pathname === "/auth/logout" && method === "POST") {
|
if (pathname === "/auth/logout" && method === "POST") {
|
||||||
const cookies = parseCookies(ctx.req.headers.get("cookie"));
|
|
||||||
const token = cookies["aurora_session"];
|
|
||||||
if (token) sessions.delete(token);
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": "aurora_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
|
"Set-Cookie": `${COOKIE_NAME}=; ${buildCookieAttributes(0)}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -240,18 +240,3 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the web server from the main application root.
|
|
||||||
* Kept for backward compatibility.
|
|
||||||
*
|
|
||||||
* @param webProjectPath - Deprecated, no longer used
|
|
||||||
* @param config - Server configuration options
|
|
||||||
* @returns Promise resolving to server instance
|
|
||||||
*/
|
|
||||||
export async function startWebServerFromRoot(
|
|
||||||
webProjectPath: string,
|
|
||||||
config: WebServerConfig = {}
|
|
||||||
): Promise<WebServerInstance> {
|
|
||||||
return createWebServer(config);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { AuroraClient } from "@/lib/BotClient";
|
import { AuroraClient } from "@/lib/BotClient";
|
||||||
import { env } from "@shared/lib/env";
|
import { env } from "@shared/lib/env";
|
||||||
import { join } from "node:path";
|
|
||||||
import { initializeConfig } from "@shared/lib/config";
|
import { initializeConfig } from "@shared/lib/config";
|
||||||
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
|
import { registerDomainEventListeners } from "@shared/lib/eventWiring";
|
||||||
import { startWebServerFromRoot } from "../api/src/server";
|
import { createWebServer } from "../api/src/server";
|
||||||
|
|
||||||
// Initialize config from database
|
// Initialize config from database
|
||||||
await initializeConfig();
|
await initializeConfig();
|
||||||
@@ -21,12 +20,11 @@ console.log("🌐 Starting web server...");
|
|||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
const webProjectPath = join(import.meta.dir, "../api");
|
|
||||||
const webPort = Number(process.env.WEB_PORT) || 3000;
|
const webPort = Number(process.env.WEB_PORT) || 3000;
|
||||||
const webHost = process.env.HOST || "0.0.0.0";
|
const webHost = process.env.HOST || "0.0.0.0";
|
||||||
|
|
||||||
// Start web server in the same process
|
// Start web server in the same process
|
||||||
const webServer = await startWebServerFromRoot(webProjectPath, {
|
const webServer = await createWebServer({
|
||||||
port: webPort,
|
port: webPort,
|
||||||
hostname: webHost,
|
hostname: webHost,
|
||||||
});
|
});
|
||||||
@@ -53,4 +51,4 @@ const shutdownHandler = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
process.on("SIGINT", shutdownHandler);
|
process.on("SIGINT", shutdownHandler);
|
||||||
process.on("SIGTERM", shutdownHandler);
|
process.on("SIGTERM", shutdownHandler);
|
||||||
|
|||||||
@@ -163,8 +163,9 @@ export function getShopListingMessage(
|
|||||||
|
|
||||||
if (line) {
|
if (line) {
|
||||||
if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 };
|
if (!tiers[rarity]) tiers[rarity] = { items: [], totalChance: 0 };
|
||||||
tiers[rarity].items.push(line);
|
const tier = tiers[rarity]!;
|
||||||
tiers[rarity].totalChance += chance;
|
tier.items.push(line);
|
||||||
|
tier.totalChance += chance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
|
"db:migrate-config": "docker compose run --rm app bun shared/scripts/migrate-config-to-db.ts",
|
||||||
"db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts",
|
"db:migrate-game-config": "docker compose run --rm app bun shared/scripts/migrate-game-settings-to-db.ts",
|
||||||
"db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'",
|
"db:migrate-all": "docker compose run --rm app sh -c 'bun shared/scripts/migrate-config-to-db.ts && bun shared/scripts/migrate-game-settings-to-db.ts'",
|
||||||
"test": "bash shared/scripts/test-sequential.sh",
|
"test": "bash shared/scripts/test-isolated.sh",
|
||||||
"test:ci": "bash shared/scripts/test-sequential.sh --integration",
|
"test:ci": "bash shared/scripts/test-isolated.sh --integration",
|
||||||
"test:simulate-ci": "bash shared/scripts/simulate-ci.sh",
|
"test:simulate-ci": "bash shared/scripts/simulate-ci.sh",
|
||||||
"panel:dev": "cd panel && bun run dev",
|
"panel:dev": "cd panel && bun run dev",
|
||||||
"panel:build": "cd panel && bun run build",
|
"panel:build": "cd panel && bun run build",
|
||||||
@@ -48,4 +48,4 @@
|
|||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ export function useAuth(): AuthState & { logout: () => Promise<void> } {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/auth/me", { credentials: "same-origin" })
|
fetch("/auth/me", { credentials: "same-origin" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: { authenticated: boolean; enrolled: boolean; user?: AuthUser }) => {
|
.then((data) => {
|
||||||
|
const auth = data as { authenticated: boolean; enrolled: boolean; user?: AuthUser };
|
||||||
setState({
|
setState({
|
||||||
loading: false,
|
loading: false,
|
||||||
user: data.authenticated ? data.user! : null,
|
user: auth.authenticated ? auth.user ?? null : null,
|
||||||
enrolled: data.enrolled ?? true,
|
enrolled: auth.enrolled ?? true,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => setState({ loading: false, user: null, enrolled: true }));
|
.catch(() => setState({ loading: false, user: null, enrolled: true }));
|
||||||
|
|||||||
@@ -28,11 +28,15 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
|||||||
const { send, subscribe, connected } = useWebSocket();
|
const { send, subscribe, connected } = useWebSocket();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigateRef = useRef(navigate);
|
const navigateRef = useRef(navigate);
|
||||||
const errorTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigateRef.current = navigate;
|
navigateRef.current = navigate;
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
useEffect(() => () => clearTimeout(errorTimerRef.current), []);
|
useEffect(() => () => {
|
||||||
|
if (errorTimerRef.current !== null) {
|
||||||
|
clearTimeout(errorTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [state, setState] = useState<GameRoomState>({
|
const [state, setState] = useState<GameRoomState>({
|
||||||
gameState: null,
|
gameState: null,
|
||||||
@@ -153,7 +157,9 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
|||||||
setTimeout(() => navigateRef.current("/games"), 2000);
|
setTimeout(() => navigateRef.current("/games"), 2000);
|
||||||
} else {
|
} else {
|
||||||
setState(prev => ({ ...prev, error: msg.message }));
|
setState(prev => ({ ...prev, error: msg.message }));
|
||||||
clearTimeout(errorTimerRef.current);
|
if (errorTimerRef.current !== null) {
|
||||||
|
clearTimeout(errorTimerRef.current);
|
||||||
|
}
|
||||||
errorTimerRef.current = setTimeout(() => {
|
errorTimerRef.current = setTimeout(() => {
|
||||||
setState(prev => ({ ...prev, error: null }));
|
setState(prev => ({ ...prev, error: null }));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function rgbToHex(r: number, g: number, b: number): string {
|
|||||||
function hexToRgb(hex: string): [number, number, number] | null {
|
function hexToRgb(hex: string): [number, number, number] | null {
|
||||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
|
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
|
return [parseInt(m[1]!, 16), parseInt(m[2]!, 16), parseInt(m[3]!, 16)];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -380,7 +380,7 @@ function AiRemoveTab({ imageFile, imageSrc, onClear }: {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-card rounded-xl overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
{resultUrl ? (
|
{resultUrl ? (
|
||||||
<div style={BG_PRESETS[bgPreset].style}>
|
<div style={BG_PRESETS[bgPreset]!.style}>
|
||||||
<img src={resultUrl} className="w-full block" alt="Result" />
|
<img src={resultUrl} className="w-full block" alt="Result" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -528,7 +528,7 @@ export function BackgroundRemoval() {
|
|||||||
const x = Math.floor((e.clientX - rect.left) * scaleX);
|
const x = Math.floor((e.clientX - rect.left) * scaleX);
|
||||||
const y = Math.floor((e.clientY - rect.top) * scaleY);
|
const y = Math.floor((e.clientY - rect.top) * scaleY);
|
||||||
const px = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data;
|
const px = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data;
|
||||||
setKeyColor([px[0], px[1], px[2]]);
|
setKeyColor([px[0]!, px[1]!, px[2]!]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHexInput = (v: string) => {
|
const handleHexInput = (v: string) => {
|
||||||
@@ -832,7 +832,7 @@ export function BackgroundRemoval() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card rounded-xl overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !hasResult && "hidden")}>
|
<div style={BG_PRESETS[bgPreset]!.style} className={cn("w-full", !hasResult && "hidden")}>
|
||||||
<canvas ref={glCanvasRef} className="w-full block" />
|
<canvas ref={glCanvasRef} className="w-full block" />
|
||||||
</div>
|
</div>
|
||||||
{!hasResult && (
|
{!hasResult && (
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ export function CanvasTool() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-card rounded-xl overflow-hidden">
|
<div className="bg-card rounded-xl overflow-hidden">
|
||||||
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !imageReady && "hidden")}>
|
<div style={BG_PRESETS[bgPreset]!.style} className={cn("w-full", !imageReady && "hidden")}>
|
||||||
<canvas ref={previewCanvasRef} className="w-full block" />
|
<canvas ref={previewCanvasRef} className="w-full block" />
|
||||||
</div>
|
</div>
|
||||||
{!imageReady && (
|
{!imageReady && (
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ export function CropTool() {
|
|||||||
let minX = width, minY = height, maxX = -1, maxY = -1;
|
let minX = width, minY = height, maxX = -1, maxY = -1;
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
if (data[(y * width + x) * 4 + 3] > 0) {
|
if (data[(y * width + x) * 4 + 3]! > 0) {
|
||||||
if (x < minX) minX = x;
|
if (x < minX) minX = x;
|
||||||
if (x > maxX) maxX = x;
|
if (x > maxX) maxX = x;
|
||||||
if (y < minY) minY = y;
|
if (y < minY) minY = y;
|
||||||
@@ -287,7 +287,7 @@ export function CropTool() {
|
|||||||
let minX = width, minY = height, maxX = -1, maxY = -1;
|
let minX = width, minY = height, maxX = -1, maxY = -1;
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < width; x++) {
|
||||||
if (data[(y * width + x) * 4 + 3] > 0) {
|
if (data[(y * width + x) * 4 + 3]! > 0) {
|
||||||
if (x < minX) minX = x;
|
if (x < minX) minX = x;
|
||||||
if (x > maxX) maxX = x;
|
if (x > maxX) maxX = x;
|
||||||
if (y < minY) minY = y;
|
if (y < minY) minY = y;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function blackjackHand(cards: Card[]): PlayerHand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeSeat(hands: PlayerHand[], activeHandIndex = 0, hasBet = true): PlayerSeat {
|
function makeSeat(hands: PlayerHand[], activeHandIndex = 0, hasBet = true): PlayerSeat {
|
||||||
return { hands, activeHandIndex, hasBet };
|
return { hands, activeHandIndex, hasBet, cumulativePnl: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a rigged state for deterministic testing. */
|
/** Create a rigged state for deterministic testing. */
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
|
|||||||
const isMyTurn = activeId === playerId && state.phase === "player_turns";
|
const isMyTurn = activeId === playerId && state.phase === "player_turns";
|
||||||
const mySeat = state.seats[playerId];
|
const mySeat = state.seats[playerId];
|
||||||
const myActiveHand = mySeat && mySeat.activeHandIndex >= 0
|
const myActiveHand = mySeat && mySeat.activeHandIndex >= 0
|
||||||
? mySeat.hands[mySeat.activeHandIndex]
|
? mySeat.hands[mySeat.activeHandIndex] ?? null
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Determine active hand index for the view
|
// Determine active hand index for the view
|
||||||
@@ -454,7 +454,7 @@ export const blackjackPlugin: GamePlugin<BlackjackState, BlackjackAction> = {
|
|||||||
myPlayerId: playerId,
|
myPlayerId: playerId,
|
||||||
phase: state.phase,
|
phase: state.phase,
|
||||||
canAct: isMyTurn,
|
canAct: isMyTurn,
|
||||||
canSplit: isMyTurn && myActiveHand !== null && canSplitHand(myActiveHand, mySeat!.hands.length),
|
canSplit: isMyTurn && myActiveHand !== null && mySeat !== undefined && canSplitHand(myActiveHand, mySeat.hands.length),
|
||||||
canDoubleDown: isMyTurn && myActiveHand !== null && canDoubleHand(myActiveHand),
|
canDoubleDown: isMyTurn && myActiveHand !== null && canDoubleHand(myActiveHand),
|
||||||
roundNumber: state.roundNumber,
|
roundNumber: state.roundNumber,
|
||||||
myCumulativePnl: mySeat?.cumulativePnl ?? 0,
|
myCumulativePnl: mySeat?.cumulativePnl ?? 0,
|
||||||
|
|||||||
@@ -43,10 +43,12 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
|||||||
createInitialState(players: string[], options?: Record<string, unknown>): ChessState {
|
createInitialState(players: string[], options?: Record<string, unknown>): ChessState {
|
||||||
const game = new Chess();
|
const game = new Chess();
|
||||||
const timeControlKey = (options?.timeControl as string) ?? "blitz_5_3";
|
const timeControlKey = (options?.timeControl as string) ?? "blitz_5_3";
|
||||||
const tc = TIME_CONTROLS[timeControlKey] ?? TIME_CONTROLS.blitz_5_3;
|
const tc = TIME_CONTROLS[timeControlKey] ?? TIME_CONTROLS["blitz_5_3"]!;
|
||||||
|
|
||||||
// Randomly assign colors
|
// Randomly assign colors
|
||||||
const shuffled = Math.random() < 0.5 ? [players[0], players[1]] : [players[1], players[0]];
|
const shuffled: [string, string] = Math.random() < 0.5
|
||||||
|
? [players[0]!, players[1]!]
|
||||||
|
: [players[1]!, players[0]!];
|
||||||
|
|
||||||
const clock: ChessClock | null = tc.time > 0
|
const clock: ChessClock | null = tc.time > 0
|
||||||
? { white: tc.time, black: tc.time, increment: tc.increment, lastMoveAt: Date.now() }
|
? { white: tc.time, black: tc.time, increment: tc.increment, lastMoveAt: Date.now() }
|
||||||
@@ -108,7 +110,7 @@ export const chessPlugin: GamePlugin<ChessState, ChessAction> = {
|
|||||||
const moveEntry = {
|
const moveEntry = {
|
||||||
from: action.from,
|
from: action.from,
|
||||||
to: action.to,
|
to: action.to,
|
||||||
san: game.history().slice(-1)[0],
|
san: game.history().slice(-1)[0]!,
|
||||||
color: turn === "white" ? "w" as const : "b" as const,
|
color: turn === "white" ? "w" as const : "b" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const LogLevelNames = {
|
|||||||
[LogLevel.ERROR]: "ERROR",
|
[LogLevel.ERROR]: "ERROR",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LogSource = "bot" | "web" | "shared" | "system";
|
export type LogSource = "auth" | "bot" | "web" | "shared" | "system";
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
|||||||
@@ -127,10 +127,10 @@ describe("questService", () => {
|
|||||||
{ userId: 1n, questId: 1, completedAt: null },
|
{ userId: 1n, questId: 1, completedAt: null },
|
||||||
{ userId: 1n, questId: 2, completedAt: new Date() },
|
{ userId: 1n, questId: 2, completedAt: new Date() },
|
||||||
]);
|
]);
|
||||||
mockReturning.mockResolvedValue([{ userId: 1n, questId: 3 }]);
|
mockReturning.mockResolvedValue([{ userId: 1n, questId: 3 }] as any);
|
||||||
|
|
||||||
const result = await questService.assignQuest("1", 3);
|
const result = await questService.assignQuest("1", 3);
|
||||||
expect(result).toEqual([{ userId: 1n, questId: 3 }]);
|
expect(result).toEqual([{ userId: 1n, questId: 3 }] as any);
|
||||||
|
|
||||||
mockGetSettings.mockRestore();
|
mockGetSettings.mockRestore();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,9 +69,11 @@ mock.module("@shared/lib/config", () => ({
|
|||||||
|
|
||||||
// Mock Events (trivia service emits domain events instead of calling dashboardService directly)
|
// Mock Events (trivia service emits domain events instead of calling dashboardService directly)
|
||||||
const mockEmit = mock(() => true);
|
const mockEmit = mock(() => true);
|
||||||
|
const mockEmitAsync = mock(async () => true);
|
||||||
mock.module("@shared/lib/events", () => ({
|
mock.module("@shared/lib/events", () => ({
|
||||||
systemEvents: {
|
systemEvents: {
|
||||||
emit: mockEmit,
|
emit: mockEmit,
|
||||||
|
emitAsync: mockEmitAsync,
|
||||||
},
|
},
|
||||||
EVENTS: {
|
EVENTS: {
|
||||||
DOMAIN: {
|
DOMAIN: {
|
||||||
@@ -115,6 +117,7 @@ describe("TriviaService", () => {
|
|||||||
mockWhere.mockClear();
|
mockWhere.mockClear();
|
||||||
mockOnConflictDoUpdate.mockClear();
|
mockOnConflictDoUpdate.mockClear();
|
||||||
mockEmit.mockClear();
|
mockEmit.mockClear();
|
||||||
|
mockEmitAsync.mockClear();
|
||||||
// Clear active sessions
|
// Clear active sessions
|
||||||
(triviaService as any).activeSessions.clear();
|
(triviaService as any).activeSessions.clear();
|
||||||
});
|
});
|
||||||
@@ -224,7 +227,7 @@ describe("TriviaService", () => {
|
|||||||
// Verify balance update
|
// Verify balance update
|
||||||
expect(mockUpdate).toHaveBeenCalledWith(users);
|
expect(mockUpdate).toHaveBeenCalledWith(users);
|
||||||
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
expect(mockInsert).toHaveBeenCalledWith(transactions);
|
||||||
expect(mockEmit).toHaveBeenCalled();
|
expect(mockEmitAsync).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not award prize for incorrect answer", async () => {
|
it("should not award prize for incorrect answer", async () => {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ if [ -n "$1" ]; then
|
|||||||
EXIT_CODE=1
|
EXIT_CODE=1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
if bash shared/scripts/test-sequential.sh --integration; then
|
if bash shared/scripts/test-isolated.sh --integration; then
|
||||||
echo "✅ CI Simulation Passed!"
|
echo "✅ CI Simulation Passed!"
|
||||||
EXIT_CODE=0
|
EXIT_CODE=0
|
||||||
else
|
else
|
||||||
|
|||||||
42
shared/scripts/test-isolated.sh
Executable file
42
shared/scripts/test-isolated.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INCLUDE_INTEGRATION=false
|
||||||
|
if [[ "${1:-}" == "--integration" ]]; then
|
||||||
|
INCLUDE_INTEGRATION=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
JOBS="${AURORA_TEST_JOBS:-4}"
|
||||||
|
|
||||||
|
echo "🔍 Finding test files..."
|
||||||
|
if [ "$INCLUDE_INTEGRATION" = true ]; then
|
||||||
|
FIND_ARGS=( -name "*.test.ts" )
|
||||||
|
else
|
||||||
|
FIND_ARGS=( -name "*.test.ts" -not -name "*.integration.test.ts" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
TEST_FILES=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
TEST_FILES+=("$file")
|
||||||
|
done < <(find . "${FIND_ARGS[@]}" -not -path "*/node_modules/*" | sort)
|
||||||
|
|
||||||
|
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
|
||||||
|
echo "⚠️ No test files found!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🧪 Running ${#TEST_FILES[@]} test files with isolated Bun processes..."
|
||||||
|
echo " Workers: $JOBS"
|
||||||
|
if [ "$INCLUDE_INTEGRATION" = true ]; then
|
||||||
|
echo " (including integration tests)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if printf '%s\n' "${TEST_FILES[@]}" | xargs -n1 -P "$JOBS" bash -lc 'echo "---------------------------------------------------"; echo "running: $1"; bun test "$1"' _; then
|
||||||
|
echo "---------------------------------------------------"
|
||||||
|
echo "✅ All tests passed!"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "---------------------------------------------------"
|
||||||
|
echo "❌ Some tests failed."
|
||||||
|
exit 1
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
INCLUDE_INTEGRATION=false
|
|
||||||
if [[ "$1" == "--integration" ]]; then
|
|
||||||
INCLUDE_INTEGRATION=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🔍 Finding test files..."
|
|
||||||
if [ "$INCLUDE_INTEGRATION" = true ]; then
|
|
||||||
TEST_FILES=$(find . -name "*.test.ts" -not -path "*/node_modules/*")
|
|
||||||
else
|
|
||||||
TEST_FILES=$(find . -name "*.test.ts" -not -name "*.integration.test.ts" -not -path "*/node_modules/*")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$TEST_FILES" ]; then
|
|
||||||
echo "⚠️ No test files found!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "🧪 Running tests sequentially..."
|
|
||||||
if [ "$INCLUDE_INTEGRATION" = true ]; then
|
|
||||||
echo " (including integration tests)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
FAILED=0
|
|
||||||
|
|
||||||
for FILE in $TEST_FILES; do
|
|
||||||
echo "---------------------------------------------------"
|
|
||||||
echo "running: $FILE"
|
|
||||||
if bun test "$FILE"; then
|
|
||||||
echo "✅ passed: $FILE"
|
|
||||||
else
|
|
||||||
echo "❌ failed: $FILE"
|
|
||||||
FAILED=1
|
|
||||||
# Fail fast
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ $FAILED -eq 0 ]; then
|
|
||||||
echo "---------------------------------------------------"
|
|
||||||
echo "✅ All tests passed!"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "---------------------------------------------------"
|
|
||||||
echo "❌ Some tests failed."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": [
|
"lib": [
|
||||||
"ESNext"
|
"ESNext",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
],
|
],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
@@ -54,4 +56,4 @@
|
|||||||
"dist",
|
"dist",
|
||||||
"drizzle"
|
"drizzle"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user