mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
build: move local development off port 3000
Run the dev API on 6262 and Vite on 6263 with strictPort; production defaults stay on 3000. Define the dev client origin once and read the typed appConfig instead of repeating its fallback in every gateway.
This commit is contained in:
@@ -36,7 +36,7 @@ env:
|
|||||||
JWT_EXPIRES_IN: 15m
|
JWT_EXPIRES_IN: 15m
|
||||||
JWT_REFRESH_EXPIRES_IN: 7d
|
JWT_REFRESH_EXPIRES_IN: 7d
|
||||||
BOOKS_PATH: ../local/data
|
BOOKS_PATH: ../local/data
|
||||||
APP_URL: http://localhost:5173
|
APP_URL: http://localhost:6263
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
run-suite:
|
run-suite:
|
||||||
|
|||||||
+2
-2
@@ -6,9 +6,9 @@ Vue 3 SPA built with Vite and Tailwind CSS v4.
|
|||||||
|
|
||||||
From the repo root: `pnpm dev` (starts both server and client).
|
From the repo root: `pnpm dev` (starts both server and client).
|
||||||
|
|
||||||
Client only: `pnpm dev` (from this directory). Runs at http://localhost:5173.
|
Client only: `pnpm dev` (from this directory). Runs at http://localhost:6263.
|
||||||
|
|
||||||
The Vite dev server proxies `/api` and `/socket.io` requests to the backend at `localhost:3000`.
|
The Vite dev server proxies `/api` and `/socket.io` requests to the backend at `localhost:6262`.
|
||||||
|
|
||||||
## IDE setup
|
## IDE setup
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { VitePWA } from 'vite-plugin-pwa'
|
|||||||
|
|
||||||
const apiAgent = new Agent({ keepAlive: true })
|
const apiAgent = new Agent({ keepAlive: true })
|
||||||
/** Lets a second dev client point at a throwaway API instance, so restart testing leaves the main stack alone. */
|
/** Lets a second dev client point at a throwaway API instance, so restart testing leaves the main stack alone. */
|
||||||
const apiTarget = process.env.BOOKORBIT_API_TARGET ?? 'http://localhost:3000'
|
const apiTarget = process.env.BOOKORBIT_API_TARGET ?? 'http://localhost:6262'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -141,6 +141,8 @@ export default defineConfig({
|
|||||||
exclude: ['@embedpdf/core', '@embedpdf/core/vue'],
|
exclude: ['@embedpdf/core', '@embedpdf/core/vue'],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
port: 6263,
|
||||||
|
strictPort: true,
|
||||||
host: true,
|
host: true,
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
+10
-10
@@ -12,8 +12,8 @@ BookOrbit is a pnpm monorepo with three workspaces: a Vue 3 single-page app, a N
|
|||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph LR
|
graph LR
|
||||||
Client["Client<br/><small>Vue 3 SPA</small><br/><small>localhost:5173</small>"]
|
Client["Client<br/><small>Vue 3 SPA</small><br/><small>localhost:6263</small>"]
|
||||||
Server["Server<br/><small>NestJS + Fastify</small><br/><small>localhost:3000/api</small>"]
|
Server["Server<br/><small>NestJS + Fastify</small><br/><small>localhost:6262/api</small>"]
|
||||||
DB["PostgreSQL<br/><small>Drizzle ORM</small>"]
|
DB["PostgreSQL<br/><small>Drizzle ORM</small>"]
|
||||||
WS["WebSocket<br/><small>Socket.IO</small>"]
|
WS["WebSocket<br/><small>Socket.IO</small>"]
|
||||||
Types["Shared Types<br/><small>@bookorbit/types</small>"]
|
Types["Shared Types<br/><small>@bookorbit/types</small>"]
|
||||||
@@ -162,8 +162,8 @@ If everything worked, you will see the server, client, and types watcher start u
|
|||||||
|
|
||||||
| Check | Expected |
|
| Check | Expected |
|
||||||
| ---------------------------------------- | ------------------------------------------ |
|
| ---------------------------------------- | ------------------------------------------ |
|
||||||
| Open http://localhost:5173 | BookOrbit login page loads |
|
| Open http://localhost:6263 | BookOrbit login page loads |
|
||||||
| Open http://localhost:3000/api/v1/health | Returns `{"status":"ok"}` |
|
| Open http://localhost:6262/api/v1/health | Returns `{"status":"ok"}` |
|
||||||
| `docker ps` | Shows `bookorbit-dev-db` container running |
|
| `docker ps` | Shows `bookorbit-dev-db` container running |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -178,8 +178,8 @@ This starts the server, client, and types watcher concurrently with hot reload.
|
|||||||
|
|
||||||
| Service | URL |
|
| Service | URL |
|
||||||
| ------- | ------------------------- |
|
| ------- | ------------------------- |
|
||||||
| Client | http://localhost:5173 |
|
| Client | http://localhost:6263 |
|
||||||
| API | http://localhost:3000/api |
|
| API | http://localhost:6262/api |
|
||||||
|
|
||||||
If PostgreSQL is not running, start it first with `pnpm run db:up`.
|
If PostgreSQL is not running, start it first with `pnpm run db:up`.
|
||||||
|
|
||||||
@@ -195,8 +195,8 @@ When enabled, open:
|
|||||||
|
|
||||||
| Docs | URL |
|
| Docs | URL |
|
||||||
| ------------ | ----------------------------------- |
|
| ------------ | ----------------------------------- |
|
||||||
| Swagger UI | http://localhost:3000/api/docs |
|
| Swagger UI | http://localhost:6262/api/docs |
|
||||||
| OpenAPI JSON | http://localhost:3000/api/docs-json |
|
| OpenAPI JSON | http://localhost:6262/api/docs-json |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Running workspaces separately</summary>
|
<summary>Running workspaces separately</summary>
|
||||||
@@ -234,7 +234,7 @@ These are the variables you are most likely to need during development. See `ser
|
|||||||
| `PODCAST_ENCRYPTION_KEY` | Podcast URL encryption key; required in production | Falls back to `JWT_SECRET` in development |
|
| `PODCAST_ENCRYPTION_KEY` | Podcast URL encryption key; required in production | Falls back to `JWT_SECRET` in development |
|
||||||
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
|
| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` |
|
||||||
| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` |
|
| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` |
|
||||||
| `APP_URL` | Base URL for email links | `http://localhost:5173` |
|
| `APP_URL` | Base URL for email links | `http://localhost:6263` |
|
||||||
| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` |
|
| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` |
|
||||||
| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` |
|
| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` |
|
||||||
|
|
||||||
@@ -471,7 +471,7 @@ pnpm run e2e:run -- <suite-id>
|
|||||||
|
|
||||||
**Port already in use**
|
**Port already in use**
|
||||||
|
|
||||||
The server defaults to port 3000 (configured via `PORT` in `server/.env`) and the client to 5173 (Vite default). If either is occupied, find the process that owns it and stop it, or change the server port in `server/.env`.
|
Local development runs the server on port 6262 (`PORT` in `server/.env`) and the client on 6263 (`server.port` in `client/vite.config.ts`, with `strictPort`, so Vite fails instead of silently moving). Both avoid 3000, which many other self-hosted apps and dev tools claim. A server started without `PORT`, such as the Docker image, still listens on 3000. If either is occupied, find the process that owns it and stop it, or change the server port in `server/.env`.
|
||||||
|
|
||||||
**Hot reload not picking up changes**
|
**Hot reload not picking up changes**
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit
|
DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit
|
||||||
|
|
||||||
# App
|
# App
|
||||||
PORT=3000
|
PORT=6262
|
||||||
# Listen address (IPv4 or IPv6, without brackets). Unset or blank keeps 0.0.0.0.
|
# Listen address (IPv4 or IPv6, without brackets). Unset or blank keeps 0.0.0.0.
|
||||||
# Use 127.0.0.1 for local-only native deployments.
|
# Use 127.0.0.1 for local-only native deployments.
|
||||||
# HOST=0.0.0.0
|
# HOST=0.0.0.0
|
||||||
@@ -92,7 +92,7 @@ APP_DATA_PATH=../local/data
|
|||||||
# DISABLE_LOCAL_AUTH=false
|
# DISABLE_LOCAL_AUTH=false
|
||||||
|
|
||||||
# App URL (used for building password reset links in emails)
|
# App URL (used for building password reset links in emails)
|
||||||
APP_URL=http://localhost:5173
|
APP_URL=http://localhost:6263
|
||||||
|
|
||||||
# Email encryption key (optional but recommended - 64-char hex string for AES-256)
|
# Email encryption key (optional but recommended - 64-char hex string for AES-256)
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
|
|||||||
+2
-2
@@ -20,8 +20,8 @@ SWAGGER_ENABLED=true pnpm start:dev
|
|||||||
|
|
||||||
Then open:
|
Then open:
|
||||||
|
|
||||||
- Swagger UI: http://localhost:3000/api/docs
|
- Swagger UI: http://localhost:6262/api/docs
|
||||||
- OpenAPI JSON: http://localhost:3000/api/docs-json
|
- OpenAPI JSON: http://localhost:6262/api/docs-json
|
||||||
|
|
||||||
Dev watch uses a Nest config without the Swagger metadata generator so the
|
Dev watch uses a Nest config without the Swagger metadata generator so the
|
||||||
server does not restart immediately after startup. Full build output still uses
|
server does not restart immediately after startup. Full build output still uses
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ afterEach(() => {
|
|||||||
describe('wsCorsOrigin', () => {
|
describe('wsCorsOrigin', () => {
|
||||||
it('falls back to the dev client when nothing is configured', () => {
|
it('falls back to the dev client when nothing is configured', () => {
|
||||||
delete process.env.CLIENT_URL;
|
delete process.env.CLIENT_URL;
|
||||||
expect(wsCorsOrigin()).toBe('http://localhost:5173');
|
expect(wsCorsOrigin()).toBe('http://localhost:6263');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops a trailing slash, which no Origin header ever carries', () => {
|
it('drops a trailing slash, which no Origin header ever carries', () => {
|
||||||
@@ -32,6 +32,6 @@ describe('wsCorsOrigin', () => {
|
|||||||
|
|
||||||
it('falls back rather than throwing on a malformed value', () => {
|
it('falls back rather than throwing on a malformed value', () => {
|
||||||
process.env.CLIENT_URL = 'not a url';
|
process.env.CLIENT_URL = 'not a url';
|
||||||
expect(wsCorsOrigin()).toBe('http://localhost:5173');
|
expect(wsCorsOrigin()).toBe('http://localhost:6263');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/** What a dev client is served from, and the only origin allowed when nothing was configured. */
|
import { DEV_CLIENT_ORIGIN } from '../../config/dev-client-origin';
|
||||||
const DEV_CLIENT_ORIGIN = 'http://localhost:5173';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The origin a socket gateway's CORS check allows.
|
* The origin a socket gateway's CORS check allows.
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ describe('config', () => {
|
|||||||
expect(appConfig()).toEqual({
|
expect(appConfig()).toEqual({
|
||||||
nodeEnv: 'development',
|
nodeEnv: 'development',
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
appUrl: 'http://localhost:5173',
|
appUrl: 'http://localhost:6263',
|
||||||
nativeRedirectUri: 'bookorbit://oauth2-callback',
|
nativeRedirectUri: 'bookorbit://oauth2-callback',
|
||||||
version: 'Local build',
|
version: 'Local build',
|
||||||
githubReleasesRepo: 'bookorbit/bookorbit',
|
githubReleasesRepo: 'bookorbit/bookorbit',
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { registerAs } from '@nestjs/config';
|
import { registerAs } from '@nestjs/config';
|
||||||
import { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
|
|
||||||
|
import { DEV_CLIENT_ORIGIN } from './dev-client-origin';
|
||||||
export const appConfig = registerAs('app', () => ({
|
export const appConfig = registerAs('app', () => ({
|
||||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||||
host: process.env.HOST?.trim() || '0.0.0.0',
|
host: process.env.HOST?.trim() || '0.0.0.0',
|
||||||
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
|
appUrl: process.env.APP_URL ?? DEV_CLIENT_ORIGIN,
|
||||||
// Private-use scheme the iOS app registers with ASWebAuthenticationSession. Compiled into the
|
// Private-use scheme the iOS app registers with ASWebAuthenticationSession. Compiled into the
|
||||||
// client binary, so it is the same for every provider on every deployment; configurable only so
|
// client binary, so it is the same for every provider on every deployment; configurable only so
|
||||||
// a rebranded fork can change it.
|
// a rebranded fork can change it.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Where `pnpm dev` serves the web client (`server.port` in `client/vite.config.ts`), and the origin
|
||||||
|
* every client-facing URL falls back to when `APP_URL` or `CLIENT_URL` is unset.
|
||||||
|
*/
|
||||||
|
export const DEV_CLIENT_ORIGIN = 'http://localhost:6263';
|
||||||
@@ -5,6 +5,8 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { APP_FEATURES } from '@bookorbit/types';
|
import { APP_FEATURES } from '@bookorbit/types';
|
||||||
|
|
||||||
|
import { DEV_CLIENT_ORIGIN } from './dev-client-origin';
|
||||||
|
|
||||||
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
|
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
|
||||||
const TRUST_PROXY_BOOLEAN_VALUES = ['true', 'false', 'yes', 'no', 'on', 'off'];
|
const TRUST_PROXY_BOOLEAN_VALUES = ['true', 'false', 'yes', 'no', 'on', 'off'];
|
||||||
|
|
||||||
@@ -81,7 +83,7 @@ const envSchema = z.object({
|
|||||||
AUDIOLESS_EPUB_MAX_METADATA_BYTES: z.coerce.number().int().positive().optional(),
|
AUDIOLESS_EPUB_MAX_METADATA_BYTES: z.coerce.number().int().positive().optional(),
|
||||||
AUDIOLESS_EPUB_MAX_OUTPUT_BYTES: z.coerce.number().int().positive().optional(),
|
AUDIOLESS_EPUB_MAX_OUTPUT_BYTES: z.coerce.number().int().positive().optional(),
|
||||||
CLIENT_URL: z.string().url().optional(),
|
CLIENT_URL: z.string().url().optional(),
|
||||||
APP_URL: z.string().url().default('http://localhost:5173'),
|
APP_URL: z.string().url().default(DEV_CLIENT_ORIGIN),
|
||||||
TRUST_PROXY: trustProxyEnv(),
|
TRUST_PROXY: trustProxyEnv(),
|
||||||
EMAIL_ENCRYPTION_KEY: z.string().optional(),
|
EMAIL_ENCRYPTION_KEY: z.string().optional(),
|
||||||
MIGRATION_ENCRYPTION_KEY: z.string().optional(),
|
MIGRATION_ENCRYPTION_KEY: z.string().optional(),
|
||||||
|
|||||||
+2
-1
@@ -14,6 +14,7 @@ import fastifyHelmet from '@fastify/helmet';
|
|||||||
import fastifyCompress from '@fastify/compress';
|
import fastifyCompress from '@fastify/compress';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { appConfig } from './config/config';
|
import { appConfig } from './config/config';
|
||||||
|
import { DEV_CLIENT_ORIGIN } from './config/dev-client-origin';
|
||||||
import { setupSwaggerDocs } from './swagger';
|
import { setupSwaggerDocs } from './swagger';
|
||||||
import {
|
import {
|
||||||
parseBooleanEnv,
|
parseBooleanEnv,
|
||||||
@@ -93,7 +94,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: process.env.CLIENT_URL ?? 'http://localhost:5173',
|
origin: process.env.CLIENT_URL ?? DEV_CLIENT_ORIGIN,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
|||||||
|
|
||||||
import { OidcService } from './oidc.service';
|
import { OidcService } from './oidc.service';
|
||||||
|
|
||||||
const APP_URL = 'http://localhost:5173';
|
const APP_URL = 'http://localhost:6263';
|
||||||
const VALID_REDIRECT_URI = `${APP_URL}/oauth2-callback`;
|
const VALID_REDIRECT_URI = `${APP_URL}/oauth2-callback`;
|
||||||
const NATIVE_REDIRECT_URI = 'bookorbit://oauth2-callback';
|
const NATIVE_REDIRECT_URI = 'bookorbit://oauth2-callback';
|
||||||
|
|
||||||
@@ -82,13 +82,7 @@ function makeService() {
|
|||||||
const auditEvents = {
|
const auditEvents = {
|
||||||
emit: vi.fn(),
|
emit: vi.fn(),
|
||||||
};
|
};
|
||||||
const configService = {
|
const appConfiguration = { appUrl: APP_URL, nativeRedirectUri: NATIVE_REDIRECT_URI };
|
||||||
get: vi.fn().mockImplementation((key: string) => {
|
|
||||||
if (key === 'app.appUrl') return APP_URL;
|
|
||||||
if (key === 'app.nativeRedirectUri') return NATIVE_REDIRECT_URI;
|
|
||||||
return undefined;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const authenticationPolicy = {
|
const authenticationPolicy = {
|
||||||
isPasswordLoginEnabled: vi.fn().mockReturnValue(true),
|
isPasswordLoginEnabled: vi.fn().mockReturnValue(true),
|
||||||
};
|
};
|
||||||
@@ -106,7 +100,7 @@ function makeService() {
|
|||||||
userService as never,
|
userService as never,
|
||||||
authService as never,
|
authService as never,
|
||||||
auditEvents as never,
|
auditEvents as never,
|
||||||
configService as never,
|
appConfiguration as never,
|
||||||
authenticationPolicy as never,
|
authenticationPolicy as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { OidcCallbackDto } from '../dto/oidc-callback.dto';
|
import { OidcCallbackDto } from '../dto/oidc-callback.dto';
|
||||||
import { BadRequestException, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, Logger, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import type { ConfigType } from '@nestjs/config';
|
||||||
import { compare } from 'bcryptjs';
|
import { compare } from 'bcryptjs';
|
||||||
import type { FastifyReply } from 'fastify';
|
import type { FastifyReply } from 'fastify';
|
||||||
import { AuditAction, AuditResource, AuthenticationMethod, OidcCallbackResponse, OidcErrorCode, Permission } from '@bookorbit/types';
|
import { AuditAction, AuditResource, AuthenticationMethod, OidcCallbackResponse, OidcErrorCode, Permission } from '@bookorbit/types';
|
||||||
@@ -20,6 +20,7 @@ import { OidcStateService } from './oidc-state.service';
|
|||||||
import { OidcTokenClientService } from './oidc-token-client.service';
|
import { OidcTokenClientService } from './oidc-token-client.service';
|
||||||
import { OidcTokenValidatorService } from './oidc-token-validator.service';
|
import { OidcTokenValidatorService } from './oidc-token-validator.service';
|
||||||
import { AuthenticationPolicyService } from '../../../common/services/authentication-policy.service';
|
import { AuthenticationPolicyService } from '../../../common/services/authentication-policy.service';
|
||||||
|
import { appConfig } from '../../../config/config';
|
||||||
|
|
||||||
function isUniqueViolation(error: unknown): boolean {
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
if (!error || typeof error !== 'object') return false;
|
if (!error || typeof error !== 'object') return false;
|
||||||
@@ -53,11 +54,11 @@ export class OidcService {
|
|||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly auditEvents: AuditEventsService,
|
private readonly auditEvents: AuditEventsService,
|
||||||
private readonly configService: ConfigService,
|
@Inject(appConfig.KEY) appConfiguration: ConfigType<typeof appConfig>,
|
||||||
private readonly authenticationPolicy: AuthenticationPolicyService,
|
private readonly authenticationPolicy: AuthenticationPolicyService,
|
||||||
) {
|
) {
|
||||||
this.appUrl = (this.configService.get<string>('app.appUrl') ?? 'http://localhost:5173').replace(/\/$/, '');
|
this.appUrl = appConfiguration.appUrl.replace(/\/$/, '');
|
||||||
this.nativeRedirectUri = this.configService.get<string>('app.nativeRedirectUri') ?? 'bookorbit://oauth2-callback';
|
this.nativeRedirectUri = appConfiguration.nativeRedirectUri;
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateState(providerSlug: string): Promise<{ state: string; authorizationEndpoint: string }> {
|
async generateState(providerSlug: string): Promise<{ state: string; authorizationEndpoint: string }> {
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { isAllowedRedirectUri } from './redirect-uri';
|
import { isAllowedRedirectUri } from './redirect-uri';
|
||||||
|
|
||||||
const POLICY = {
|
const POLICY = {
|
||||||
appUrl: 'http://localhost:5173',
|
appUrl: 'http://localhost:6263',
|
||||||
nativeRedirectUri: 'bookorbit://oauth2-callback',
|
nativeRedirectUri: 'bookorbit://oauth2-callback',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('isAllowedRedirectUri', () => {
|
describe('isAllowedRedirectUri', () => {
|
||||||
describe('web redirect URI', () => {
|
describe('web redirect URI', () => {
|
||||||
it('accepts the exact web callback', () => {
|
it('accepts the exact web callback', () => {
|
||||||
expect(isAllowedRedirectUri('http://localhost:5173/oauth2-callback', POLICY)).toBe(true);
|
expect(isAllowedRedirectUri('http://localhost:6263/oauth2-callback', POLICY)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a web callback carrying a query string', () => {
|
it('accepts a web callback carrying a query string', () => {
|
||||||
expect(isAllowedRedirectUri('http://localhost:5173/oauth2-callback?redirect=/library', POLICY)).toBe(true);
|
expect(isAllowedRedirectUri('http://localhost:6263/oauth2-callback?redirect=/library', POLICY)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tolerates a trailing slash on the configured app URL', () => {
|
it('tolerates a trailing slash on the configured app URL', () => {
|
||||||
expect(isAllowedRedirectUri('http://localhost:5173/oauth2-callback', { ...POLICY, appUrl: 'http://localhost:5173/' })).toBe(true);
|
expect(isAllowedRedirectUri('http://localhost:6263/oauth2-callback', { ...POLICY, appUrl: 'http://localhost:6263/' })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a different origin', () => {
|
it('rejects a different origin', () => {
|
||||||
@@ -24,7 +24,7 @@ describe('isAllowedRedirectUri', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a different path on the right origin', () => {
|
it('rejects a different path on the right origin', () => {
|
||||||
expect(isAllowedRedirectUri('http://localhost:5173/somewhere-else', POLICY)).toBe(false);
|
expect(isAllowedRedirectUri('http://localhost:6263/somewhere-else', POLICY)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface RedirectUriPolicy {
|
export interface RedirectUriPolicy {
|
||||||
/** Web client origin, e.g. `http://localhost:5173`. Trailing slash already stripped. */
|
/** Web client origin, e.g. `https://books.example.com`. Trailing slash already stripped. */
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
/** Private-use scheme the native clients hand to the IdP, e.g. `bookorbit://oauth2-callback`. */
|
/** Private-use scheme the native clients hand to the IdP, e.g. `bookorbit://oauth2-callback`. */
|
||||||
nativeRedirectUri: string;
|
nativeRedirectUri: string;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function makeGateway() {
|
|||||||
const queueRepo = { getStatusSummary: vi.fn() };
|
const queueRepo = { getStatusSummary: vi.fn() };
|
||||||
const enrichmentConfig = { isPaused: vi.fn() };
|
const enrichmentConfig = { isPaused: vi.fn() };
|
||||||
const session = { getSnapshot: vi.fn() };
|
const session = { getSnapshot: vi.fn() };
|
||||||
const configService = { get: vi.fn().mockReturnValue('http://localhost:5173') };
|
const appConfiguration = { appUrl: 'http://localhost:6263' };
|
||||||
|
|
||||||
const gateway = new AuthorEnrichmentGateway(
|
const gateway = new AuthorEnrichmentGateway(
|
||||||
jwtService as any,
|
jwtService as any,
|
||||||
@@ -17,7 +17,7 @@ function makeGateway() {
|
|||||||
queueRepo as any,
|
queueRepo as any,
|
||||||
enrichmentConfig as any,
|
enrichmentConfig as any,
|
||||||
session as any,
|
session as any,
|
||||||
configService as any,
|
appConfiguration as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { gateway, jwtService, authService, queueRepo, enrichmentConfig, session };
|
return { gateway, jwtService, authService, queueRepo, enrichmentConfig, session };
|
||||||
@@ -90,7 +90,7 @@ describe('AuthorEnrichmentGateway', () => {
|
|||||||
|
|
||||||
expect(server.engine.opts.cors).toEqual({
|
expect(server.engine.opts.cors).toEqual({
|
||||||
methods: ['GET'],
|
methods: ['GET'],
|
||||||
origin: 'http://localhost:5173',
|
origin: 'http://localhost:6263',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
|
import { ForbiddenException, Inject, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import type { ConfigType } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||||
import { Permission, type AuthenticationMethod, type AuthorEnrichmentStatusEvent } from '@bookorbit/types';
|
import { Permission, type AuthenticationMethod, type AuthorEnrichmentStatusEvent } from '@bookorbit/types';
|
||||||
@@ -12,6 +12,7 @@ import { AuthorEnrichmentRepository } from './author-enrichment.repository';
|
|||||||
import { AuthorEnrichmentSessionService } from './author-enrichment-session.service';
|
import { AuthorEnrichmentSessionService } from './author-enrichment-session.service';
|
||||||
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
|
import { AuthorEnrichmentConfigService } from './author-enrichment-config.service';
|
||||||
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
||||||
|
import { appConfig } from '../../config/config';
|
||||||
|
|
||||||
export const AUTHOR_ENRICHMENT_STATUS_EVENT = 'author-enrichment:status';
|
export const AUTHOR_ENRICHMENT_STATUS_EVENT = 'author-enrichment:status';
|
||||||
|
|
||||||
@@ -27,9 +28,9 @@ export class AuthorEnrichmentGateway implements OnGatewayInit, OnGatewayConnecti
|
|||||||
private readonly queueRepo: AuthorEnrichmentRepository,
|
private readonly queueRepo: AuthorEnrichmentRepository,
|
||||||
private readonly enrichmentConfig: AuthorEnrichmentConfigService,
|
private readonly enrichmentConfig: AuthorEnrichmentConfigService,
|
||||||
private readonly session: AuthorEnrichmentSessionService,
|
private readonly session: AuthorEnrichmentSessionService,
|
||||||
config: ConfigService,
|
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
|
||||||
) {
|
) {
|
||||||
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
this.clientOrigin = app.appUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterInit(server: Server): void {
|
afterInit(server: Server): void {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe('KoboSyncController', () => {
|
|||||||
const req = {
|
const req = {
|
||||||
headers: {
|
headers: {
|
||||||
'x-forwarded-host': '192.168.8.134',
|
'x-forwarded-host': '192.168.8.134',
|
||||||
'x-forwarded-port': '5173',
|
'x-forwarded-port': '6263',
|
||||||
'x-forwarded-proto': 'http',
|
'x-forwarded-proto': 'http',
|
||||||
host: '192.168.8.134',
|
host: '192.168.8.134',
|
||||||
},
|
},
|
||||||
@@ -108,9 +108,9 @@ describe('KoboSyncController', () => {
|
|||||||
const payload = controller.initialization({ deviceToken: 'dev' } as never, req as never);
|
const payload = controller.initialization({ deviceToken: 'dev' } as never, req as never);
|
||||||
const resources = payload.Resources as Record<string, string>;
|
const resources = payload.Resources as Record<string, string>;
|
||||||
|
|
||||||
expect(resources.image_host).toBe('http://192.168.8.134:5173');
|
expect(resources.image_host).toBe('http://192.168.8.134:6263');
|
||||||
expect(resources.library_sync).toBe('http://192.168.8.134:5173/api/v1/kobo/dev/v1/library/sync');
|
expect(resources.library_sync).toBe('http://192.168.8.134:6263/api/v1/kobo/dev/v1/library/sync');
|
||||||
expect(resources.reading_state).toBe('http://192.168.8.134:5173/api/v1/kobo/dev/v1/library/{Ids}/state');
|
expect(resources.reading_state).toBe('http://192.168.8.134:6263/api/v1/kobo/dev/v1/library/{Ids}/state');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('initialization does not append x-forwarded-port when it is the default for the scheme', () => {
|
it('initialization does not append x-forwarded-port when it is the default for the scheme', () => {
|
||||||
|
|||||||
@@ -31,12 +31,15 @@ import { KoboSyncService } from './services/kobo-sync.service';
|
|||||||
import { KoboReadingStateService } from './services/kobo-reading-state.service';
|
import { KoboReadingStateService } from './services/kobo-reading-state.service';
|
||||||
import type { KoboProxyResponse } from './services/kobo-proxy.service';
|
import type { KoboProxyResponse } from './services/kobo-proxy.service';
|
||||||
import { KoboProxyService } from './services/kobo-proxy.service';
|
import { KoboProxyService } from './services/kobo-proxy.service';
|
||||||
|
import { DEV_CLIENT_ORIGIN } from '../../config/dev-client-origin';
|
||||||
import { KOBO_STORE_RESOURCES } from './kobo-store-resources';
|
import { KOBO_STORE_RESOURCES } from './kobo-store-resources';
|
||||||
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
|
import { KoboBookIdentityService } from './services/kobo-book-identity.service';
|
||||||
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
|
import { KoboSyncHistoryService } from './services/kobo-sync-history.service';
|
||||||
import { decodeSyncToken, isUsableKoboSyncToken, withKoboSyncToken } from './services/kobo-sync-token';
|
import { decodeSyncToken, isUsableKoboSyncToken, withKoboSyncToken } from './services/kobo-sync-token';
|
||||||
|
|
||||||
const STORE_SYNC_TIMEOUT_MS = 8_000;
|
const STORE_SYNC_TIMEOUT_MS = 8_000;
|
||||||
|
// A request that reached the API through the Vite dev proxy names the client's port, not the API's.
|
||||||
|
const DEV_CLIENT_PORT = new URL(DEV_CLIENT_ORIGIN).port;
|
||||||
const KOBO_SYNC_TOKEN_HEADER = 'x-kobo-synctoken';
|
const KOBO_SYNC_TOKEN_HEADER = 'x-kobo-synctoken';
|
||||||
|
|
||||||
// Kobo answers a delete for a tag it does not know with a client error, and the device keeps the
|
// Kobo answers a delete for a tag it does not know with a client error, and the device keeps the
|
||||||
@@ -88,7 +91,7 @@ function buildReadingServicesBaseUrl(req: FastifyRequest, baseUrl: string): stri
|
|||||||
if (!localPort) return baseUrl;
|
if (!localPort) return baseUrl;
|
||||||
|
|
||||||
const url = new URL(baseUrl);
|
const url = new URL(baseUrl);
|
||||||
if (url.port !== '5173') return baseUrl;
|
if (url.port !== DEV_CLIENT_PORT) return baseUrl;
|
||||||
|
|
||||||
const proto = readHeaderValue(req.headers['x-forwarded-proto']) ?? req.protocol;
|
const proto = readHeaderValue(req.headers['x-forwarded-proto']) ?? req.protocol;
|
||||||
url.protocol = proto + ':';
|
url.protocol = proto + ':';
|
||||||
|
|||||||
@@ -35,11 +35,9 @@ function makeGateway() {
|
|||||||
findRunById: vi.fn(),
|
findRunById: vi.fn(),
|
||||||
listRunMetrics: vi.fn(),
|
listRunMetrics: vi.fn(),
|
||||||
};
|
};
|
||||||
const configService = {
|
const appConfiguration = { appUrl: 'http://localhost:6263' };
|
||||||
get: vi.fn().mockReturnValue('http://localhost:5173'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const gateway = new MigrationProgressGateway(jwtService as never, authService as never, repo as never, configService as never);
|
const gateway = new MigrationProgressGateway(jwtService as never, authService as never, repo as never, appConfiguration as never);
|
||||||
return { gateway, jwtService, authService, repo };
|
return { gateway, jwtService, authService, repo };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +62,7 @@ describe('MigrationProgressGateway', () => {
|
|||||||
|
|
||||||
expect(server.engine.opts.cors).toEqual({
|
expect(server.engine.opts.cors).toEqual({
|
||||||
methods: ['GET'],
|
methods: ['GET'],
|
||||||
origin: 'http://localhost:5173',
|
origin: 'http://localhost:6263',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
|
import { ForbiddenException, Inject, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import type { ConfigType } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
@@ -11,6 +11,7 @@ import { AuthService } from '../auth/auth.service';
|
|||||||
import { MigrationRepository } from './migration.repository';
|
import { MigrationRepository } from './migration.repository';
|
||||||
import { sanitizeRunForApi } from './core/api-sanitizers';
|
import { sanitizeRunForApi } from './core/api-sanitizers';
|
||||||
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
||||||
|
import { appConfig } from '../../config/config';
|
||||||
|
|
||||||
@WebSocketGateway({ namespace: '/migration', cors: { credentials: true } })
|
@WebSocketGateway({ namespace: '/migration', cors: { credentials: true } })
|
||||||
export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
@@ -22,9 +23,9 @@ export class MigrationProgressGateway implements OnGatewayInit, OnGatewayConnect
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly repo: MigrationRepository,
|
private readonly repo: MigrationRepository,
|
||||||
config: ConfigService,
|
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
|
||||||
) {
|
) {
|
||||||
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
this.clientOrigin = app.appUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterInit(server: Server): void {
|
afterInit(server: Server): void {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { NotificationGateway } from './notification.gateway';
|
|||||||
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
|
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
|
||||||
|
|
||||||
function makeGateway() {
|
function makeGateway() {
|
||||||
return new NotificationGateway({} as never, {} as never, {} as never, { get: vi.fn().mockReturnValue('http://localhost:5173') } as never);
|
return new NotificationGateway({} as never, {} as never, {} as never, { appUrl: 'http://localhost:6263' } as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
|
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
|
||||||
@@ -84,7 +84,7 @@ describe('NotificationGateway handshake rejection', () => {
|
|||||||
};
|
};
|
||||||
const userService = { findByIdWithPermissions: vi.fn().mockResolvedValue(overrides.user ?? null) };
|
const userService = { findByIdWithPermissions: vi.fn().mockResolvedValue(overrides.user ?? null) };
|
||||||
const notificationRepo = { countUnread: vi.fn().mockResolvedValue(0) };
|
const notificationRepo = { countUnread: vi.fn().mockResolvedValue(0) };
|
||||||
const config = { get: vi.fn().mockReturnValue('http://localhost:5173') };
|
const config = { appUrl: 'http://localhost:6263' };
|
||||||
const gateway = new NotificationGateway(jwtService as never, userService as never, notificationRepo as never, config as never);
|
const gateway = new NotificationGateway(jwtService as never, userService as never, notificationRepo as never, config as never);
|
||||||
const client = { id: 'sock-1', handshake: { auth: { token: 'jwt' } }, data: {}, join: vi.fn(), emit: vi.fn(), disconnect: vi.fn() };
|
const client = { id: 'sock-1', handshake: { auth: { token: 'jwt' } }, data: {}, join: vi.fn(), emit: vi.fn(), disconnect: vi.fn() };
|
||||||
return { gateway, client };
|
return { gateway, client };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
|
import { ForbiddenException, Inject, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import type { ConfigType } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||||
import { Permission } from '@bookorbit/types';
|
import { Permission } from '@bookorbit/types';
|
||||||
@@ -11,6 +11,7 @@ import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
|||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { NotificationRepository } from './notification.repository';
|
import { NotificationRepository } from './notification.repository';
|
||||||
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
||||||
|
import { appConfig } from '../../config/config';
|
||||||
|
|
||||||
@WebSocketGateway({ namespace: '/notifications', cors: { credentials: true } })
|
@WebSocketGateway({ namespace: '/notifications', cors: { credentials: true } })
|
||||||
export class NotificationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
export class NotificationGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
@@ -22,9 +23,9 @@ export class NotificationGateway implements OnGatewayInit, OnGatewayConnection,
|
|||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly notificationRepo: NotificationRepository,
|
private readonly notificationRepo: NotificationRepository,
|
||||||
config: ConfigService,
|
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
|
||||||
) {
|
) {
|
||||||
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
this.clientOrigin = app.appUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterInit(server: Server): void {
|
afterInit(server: Server): void {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe('podcast event stream over HTTP', () => {
|
|||||||
|
|
||||||
events = app.get(PodcastEventsService);
|
events = app.get(PodcastEventsService);
|
||||||
socketEmit = vi.fn();
|
socketEmit = vi.fn();
|
||||||
gateway = new PodcastGateway({} as never, {} as never, {} as never, events, { get: vi.fn().mockReturnValue('http://localhost:5173') } as never);
|
gateway = new PodcastGateway({} as never, {} as never, {} as never, events, { appUrl: 'http://localhost:6263' } as never);
|
||||||
gateway['server'] = { to: vi.fn().mockReturnValue({ emit: socketEmit }) } as never;
|
gateway['server'] = { to: vi.fn().mockReturnValue({ emit: socketEmit }) } as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function makeGateway() {
|
|||||||
publishImportProgress: vi.fn(),
|
publishImportProgress: vi.fn(),
|
||||||
publishRetentionEvicted: vi.fn(),
|
publishRetentionEvicted: vi.fn(),
|
||||||
};
|
};
|
||||||
const config = { get: vi.fn().mockReturnValue('http://localhost:5173') };
|
const config = { appUrl: 'http://localhost:6263' };
|
||||||
const gateway = new PodcastGateway(jwtService as never, authService as never, libraries as never, events as never, config as never);
|
const gateway = new PodcastGateway(jwtService as never, authService as never, libraries as never, events as never, config as never);
|
||||||
return { gateway, jwtService, authService, libraries, events };
|
return { gateway, jwtService, authService, libraries, events };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Logger, UnauthorizedException } from '@nestjs/common';
|
import { Inject, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import type { ConfigType } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
@@ -19,6 +19,7 @@ import { AuthService } from '../auth/auth.service';
|
|||||||
import { LibraryService } from '../library/library.service';
|
import { LibraryService } from '../library/library.service';
|
||||||
import { PodcastEventsService } from './podcast-events.service';
|
import { PodcastEventsService } from './podcast-events.service';
|
||||||
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
||||||
|
import { appConfig } from '../../config/config';
|
||||||
|
|
||||||
@WebSocketGateway({ namespace: '/podcasts', cors: { credentials: true } })
|
@WebSocketGateway({ namespace: '/podcasts', cors: { credentials: true } })
|
||||||
export class PodcastGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
export class PodcastGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
|
||||||
@@ -31,9 +32,9 @@ export class PodcastGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
|||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly libraries: LibraryService,
|
private readonly libraries: LibraryService,
|
||||||
private readonly events: PodcastEventsService,
|
private readonly events: PodcastEventsService,
|
||||||
config: ConfigService,
|
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
|
||||||
) {
|
) {
|
||||||
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
this.clientOrigin = app.appUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterInit(server: Server): void {
|
afterInit(server: Server): void {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ function makeGateway() {
|
|||||||
const authService = { validateSessionUser: vi.fn() };
|
const authService = { validateSessionUser: vi.fn() };
|
||||||
const scanJobStore = { get: vi.fn(), isRunning: vi.fn() };
|
const scanJobStore = { get: vi.fn(), isRunning: vi.fn() };
|
||||||
const achievementEvents = new AchievementEventsService();
|
const achievementEvents = new AchievementEventsService();
|
||||||
const configService = { get: vi.fn().mockReturnValue('http://localhost:5173') };
|
const appConfiguration = { appUrl: 'http://localhost:6263' };
|
||||||
const gateway = new ScanGateway(jwtService as any, authService as any, scanJobStore as any, achievementEvents, configService as any);
|
const gateway = new ScanGateway(jwtService as any, authService as any, scanJobStore as any, achievementEvents, appConfiguration as any);
|
||||||
return { gateway, jwtService, authService, scanJobStore, achievementEvents };
|
return { gateway, jwtService, authService, scanJobStore, achievementEvents };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ describe('subscription lifecycle', () => {
|
|||||||
|
|
||||||
expect(server.engine.opts.cors).toEqual({
|
expect(server.engine.opts.cors).toEqual({
|
||||||
methods: ['GET'],
|
methods: ['GET'],
|
||||||
origin: 'http://localhost:5173',
|
origin: 'http://localhost:6263',
|
||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Logger, OnModuleDestroy, OnModuleInit, UnauthorizedException } from '@nestjs/common';
|
import { Inject, Logger, OnModuleDestroy, OnModuleInit, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import type { ConfigType } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from '../achievement/achievement-events.service';
|
} from '../achievement/achievement-events.service';
|
||||||
import { ScanJobStore } from './scan-job-store.service';
|
import { ScanJobStore } from './scan-job-store.service';
|
||||||
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
||||||
|
import { appConfig } from '../../config/config';
|
||||||
|
|
||||||
@WebSocketGateway({ namespace: '/scan', cors: { credentials: true } })
|
@WebSocketGateway({ namespace: '/scan', cors: { credentials: true } })
|
||||||
export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, OnModuleInit, OnModuleDestroy {
|
export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, OnModuleInit, OnModuleDestroy {
|
||||||
@@ -40,9 +41,9 @@ export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly scanJobStore: ScanJobStore,
|
private readonly scanJobStore: ScanJobStore,
|
||||||
private readonly achievementEvents: AchievementEventsService,
|
private readonly achievementEvents: AchievementEventsService,
|
||||||
config: ConfigService,
|
@Inject(appConfig.KEY) app: ConfigType<typeof appConfig>,
|
||||||
) {
|
) {
|
||||||
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
this.clientOrigin = app.appUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit(): void {
|
onModuleInit(): void {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('UserService', () => {
|
|||||||
replaceFilters: vi.fn(),
|
replaceFilters: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = { get: vi.fn() };
|
const appConfiguration = { appUrl: '' };
|
||||||
const appSettingsService = {
|
const appSettingsService = {
|
||||||
getDefaultLibraryAccessLibraryIds: vi.fn(),
|
getDefaultLibraryAccessLibraryIds: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -68,13 +68,21 @@ describe('UserService', () => {
|
|||||||
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
events = new UserEventsService();
|
events = new UserEventsService();
|
||||||
service = new UserService(userRepo as any, config as any, contentFilterRepo as any, appSettingsService as any, userStatistics as any, events, {
|
service = new UserService(
|
||||||
|
userRepo as any,
|
||||||
|
appConfiguration as any,
|
||||||
|
contentFilterRepo as any,
|
||||||
|
appSettingsService as any,
|
||||||
|
userStatistics as any,
|
||||||
|
events,
|
||||||
|
{
|
||||||
assertPasswordLoginEnabled: vi.fn(),
|
assertPasswordLoginEnabled: vi.fn(),
|
||||||
} as any);
|
} as any,
|
||||||
|
);
|
||||||
|
|
||||||
mockHash.mockResolvedValue('hashed-secret');
|
mockHash.mockResolvedValue('hashed-secret');
|
||||||
mockRandomBytes.mockReturnValue(Buffer.from('abcd', 'hex'));
|
mockRandomBytes.mockReturnValue(Buffer.from('abcd', 'hex'));
|
||||||
config.get.mockReturnValue('https://app.example.com');
|
appConfiguration.appUrl = 'https://app.example.com';
|
||||||
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
|
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
|
||||||
|
|
||||||
userRepo.create.mockResolvedValue({ id: 10, username: 'newuser', name: 'New User' });
|
userRepo.create.mockResolvedValue({ id: 10, username: 'newuser', name: 'New User' });
|
||||||
@@ -196,7 +204,6 @@ describe('UserService', () => {
|
|||||||
|
|
||||||
it('createUser skips permission/library writes when lists are empty', async () => {
|
it('createUser skips permission/library writes when lists are empty', async () => {
|
||||||
userRepo.findByUsername.mockResolvedValue(null);
|
userRepo.findByUsername.mockResolvedValue(null);
|
||||||
config.get.mockReturnValue(undefined);
|
|
||||||
|
|
||||||
const result = await service.createUser({
|
const result = await service.createUser({
|
||||||
username: 'newuser',
|
username: 'newuser',
|
||||||
@@ -208,7 +215,7 @@ describe('UserService', () => {
|
|||||||
|
|
||||||
expect(userRepo.setPermissions).not.toHaveBeenCalled();
|
expect(userRepo.setPermissions).not.toHaveBeenCalled();
|
||||||
expect(userRepo.assignViewerLibraries).not.toHaveBeenCalled();
|
expect(userRepo.assignViewerLibraries).not.toHaveBeenCalled();
|
||||||
expect(result.resetUrl).toBe('http://localhost:5173/reset-password?token=reset-token');
|
expect(result.resetUrl).toBe('https://app.example.com/reset-password?token=reset-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('findById returns user and throws when missing', async () => {
|
it('findById returns user and throws when missing', async () => {
|
||||||
@@ -775,13 +782,13 @@ describe('UserService', () => {
|
|||||||
await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adminResetPassword returns default app URL when config is missing', async () => {
|
it('adminResetPassword builds the reset link from the configured app URL', async () => {
|
||||||
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false, provisioningMethod: 'local' });
|
userRepo.findByIdWithPermissions.mockResolvedValue({ id: 2, isSuperuser: false, provisioningMethod: 'local' });
|
||||||
userRepo.generateResetToken.mockResolvedValue('token-2');
|
userRepo.generateResetToken.mockResolvedValue('token-2');
|
||||||
config.get.mockReturnValue(undefined);
|
appConfiguration.appUrl = 'http://localhost:6263';
|
||||||
|
|
||||||
await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).resolves.toEqual({
|
await expect(service.adminResetPassword(2, reqUser({ isSuperuser: true }))).resolves.toEqual({
|
||||||
resetUrl: 'http://localhost:5173/reset-password?token=token-2',
|
resetUrl: 'http://localhost:6263/reset-password?token=token-2',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -814,7 +821,7 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
|
|||||||
findByUserIdWithNames: vi.fn(),
|
findByUserIdWithNames: vi.fn(),
|
||||||
replaceFilters: vi.fn(),
|
replaceFilters: vi.fn(),
|
||||||
};
|
};
|
||||||
const config = { get: vi.fn() };
|
const appConfiguration = { appUrl: '' };
|
||||||
const appSettingsService = {
|
const appSettingsService = {
|
||||||
getDefaultLibraryAccessLibraryIds: vi.fn(),
|
getDefaultLibraryAccessLibraryIds: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -824,11 +831,11 @@ describe('UserService.updateSeriesCollapsePreferences', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
config.get.mockReturnValue('http://localhost:5173');
|
appConfiguration.appUrl = 'http://localhost:6263';
|
||||||
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
|
appSettingsService.getDefaultLibraryAccessLibraryIds.mockResolvedValue([]);
|
||||||
service = new UserService(
|
service = new UserService(
|
||||||
userRepo as any,
|
userRepo as any,
|
||||||
config as any,
|
appConfiguration as any,
|
||||||
contentFilterRepo as any,
|
contentFilterRepo as any,
|
||||||
appSettingsService as any,
|
appSettingsService as any,
|
||||||
userStatistics as any,
|
userStatistics as any,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import type { ConfigType } from '@nestjs/config';
|
||||||
import { hash } from 'bcryptjs';
|
import { hash } from 'bcryptjs';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { Permission } from '@bookorbit/types';
|
import { Permission } from '@bookorbit/types';
|
||||||
@@ -22,6 +22,7 @@ import { UserRepository, type UserListQuery } from './user.repository';
|
|||||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||||
import { UserStatisticsService } from '../user-statistics/user-statistics.service';
|
import { UserStatisticsService } from '../user-statistics/user-statistics.service';
|
||||||
import { AuthenticationPolicyService } from '../../common/services/authentication-policy.service';
|
import { AuthenticationPolicyService } from '../../common/services/authentication-policy.service';
|
||||||
|
import { appConfig } from '../../config/config';
|
||||||
|
|
||||||
/** The band is a to-do list, not a second roster. */
|
/** The band is a to-do list, not a second roster. */
|
||||||
const ATTENTION_BAND_LIMIT = 8;
|
const ATTENTION_BAND_LIMIT = 8;
|
||||||
@@ -33,7 +34,7 @@ export class UserService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly userRepo: UserRepository,
|
private readonly userRepo: UserRepository,
|
||||||
private readonly config: ConfigService,
|
@Inject(appConfig.KEY) private readonly appConfiguration: ConfigType<typeof appConfig>,
|
||||||
private readonly contentFilterRepo: ContentFilterRepository,
|
private readonly contentFilterRepo: ContentFilterRepository,
|
||||||
private readonly appSettingsService: AppSettingsService,
|
private readonly appSettingsService: AppSettingsService,
|
||||||
private readonly userStatistics: UserStatisticsService,
|
private readonly userStatistics: UserStatisticsService,
|
||||||
@@ -158,7 +159,7 @@ export class UserService {
|
|||||||
await this.userRepo.assignViewerLibraries(user.id, libraryIds);
|
await this.userRepo.assignViewerLibraries(user.id, libraryIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
const appUrl = this.appConfiguration.appUrl;
|
||||||
const rawToken = await this.userRepo.generateResetToken(user.id);
|
const rawToken = await this.userRepo.generateResetToken(user.id);
|
||||||
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
|
const resetUrl = `${appUrl}/reset-password?token=${rawToken}`;
|
||||||
|
|
||||||
@@ -439,7 +440,7 @@ export class UserService {
|
|||||||
if (target.provisioningMethod === 'shared') {
|
if (target.provisioningMethod === 'shared') {
|
||||||
throw new BadRequestException('Shared accounts do not have passwords');
|
throw new BadRequestException('Shared accounts do not have passwords');
|
||||||
}
|
}
|
||||||
const appUrl = this.config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
const appUrl = this.appConfiguration.appUrl;
|
||||||
const rawToken = await this.userRepo.generateResetToken(targetUserId);
|
const rawToken = await this.userRepo.generateResetToken(targetUserId);
|
||||||
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };
|
return { resetUrl: `${appUrl}/reset-password?token=${rawToken}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const REQUEST_DELAY_MS = Number.parseInt(process.env.BENCH_REQUEST_DELAY_MS ?? '
|
|||||||
const MAX_429_RETRIES = Number.parseInt(process.env.BENCH_MAX_429_RETRIES ?? '2', 10);
|
const MAX_429_RETRIES = Number.parseInt(process.env.BENCH_MAX_429_RETRIES ?? '2', 10);
|
||||||
const OUTPUT_PATH = process.env.BENCH_OUTPUT_PATH ?? '';
|
const OUTPUT_PATH = process.env.BENCH_OUTPUT_PATH ?? '';
|
||||||
const BENCH_BASE_URL = process.env.BENCH_BASE_URL;
|
const BENCH_BASE_URL = process.env.BENCH_BASE_URL;
|
||||||
const CANDIDATE_BASE_URLS = ['http://localhost:3010', 'http://localhost:3000'];
|
const CANDIDATE_BASE_URLS = ['http://localhost:6262', 'http://localhost:3010', 'http://localhost:3000'];
|
||||||
|
|
||||||
const CASES: BenchCase[] = [
|
const CASES: BenchCase[] = [
|
||||||
{ name: 'summary', path: '/api/v1/user-statistics/summary' },
|
{ name: 'summary', path: '/api/v1/user-statistics/summary' },
|
||||||
@@ -79,7 +79,7 @@ async function resolveBaseUrl(): Promise<string> {
|
|||||||
if (await isHealthy(candidate)) return candidate;
|
if (await isHealthy(candidate)) return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Could not detect a running API base URL. Set BENCH_BASE_URL (for example: http://localhost:3000).');
|
throw new Error('Could not detect a running API base URL. Set BENCH_BASE_URL (for example: http://localhost:6262).');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBenchUser(): Promise<{ id: number; tokenVersion: number }> {
|
async function getBenchUser(): Promise<{ id: number; tokenVersion: number }> {
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
|||||||
const callbackPayload = {
|
const callbackPayload = {
|
||||||
code: 'authorization-code-provisioned',
|
code: 'authorization-code-provisioned',
|
||||||
codeVerifier: 'verifier-provisioned',
|
codeVerifier: 'verifier-provisioned',
|
||||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
redirectUri: 'http://localhost:6263/oauth2-callback',
|
||||||
nonce: 'nonce-provisioned',
|
nonce: 'nonce-provisioned',
|
||||||
state,
|
state,
|
||||||
};
|
};
|
||||||
@@ -691,7 +691,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
|||||||
payload: {
|
payload: {
|
||||||
code: 'authorization-code-linked',
|
code: 'authorization-code-linked',
|
||||||
codeVerifier: 'verifier-linked',
|
codeVerifier: 'verifier-linked',
|
||||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
redirectUri: 'http://localhost:6263/oauth2-callback',
|
||||||
nonce: 'nonce-linked',
|
nonce: 'nonce-linked',
|
||||||
state,
|
state,
|
||||||
},
|
},
|
||||||
@@ -800,7 +800,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
|||||||
payload: {
|
payload: {
|
||||||
code: 'authorization-code-invalid-state',
|
code: 'authorization-code-invalid-state',
|
||||||
codeVerifier: 'verifier-invalid-state',
|
codeVerifier: 'verifier-invalid-state',
|
||||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
redirectUri: 'http://localhost:6263/oauth2-callback',
|
||||||
nonce: 'nonce-invalid-state',
|
nonce: 'nonce-invalid-state',
|
||||||
state: `invalid-${randomUUID()}`,
|
state: `invalid-${randomUUID()}`,
|
||||||
},
|
},
|
||||||
@@ -822,7 +822,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
|||||||
payload: {
|
payload: {
|
||||||
code: 'authorization-code-missing-subject',
|
code: 'authorization-code-missing-subject',
|
||||||
codeVerifier: 'verifier-missing-subject',
|
codeVerifier: 'verifier-missing-subject',
|
||||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
redirectUri: 'http://localhost:6263/oauth2-callback',
|
||||||
nonce: 'nonce-missing-subject',
|
nonce: 'nonce-missing-subject',
|
||||||
state,
|
state,
|
||||||
},
|
},
|
||||||
@@ -864,7 +864,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
|||||||
payload: {
|
payload: {
|
||||||
code: 'authorization-code-disabled',
|
code: 'authorization-code-disabled',
|
||||||
codeVerifier: 'verifier-disabled',
|
codeVerifier: 'verifier-disabled',
|
||||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
redirectUri: 'http://localhost:6263/oauth2-callback',
|
||||||
nonce: 'nonce-disabled',
|
nonce: 'nonce-disabled',
|
||||||
state: disabledState,
|
state: disabledState,
|
||||||
},
|
},
|
||||||
@@ -903,7 +903,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, (
|
|||||||
payload: {
|
payload: {
|
||||||
code: 'authorization-code-inactive',
|
code: 'authorization-code-inactive',
|
||||||
codeVerifier: 'verifier-inactive',
|
codeVerifier: 'verifier-inactive',
|
||||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
redirectUri: 'http://localhost:6263/oauth2-callback',
|
||||||
nonce: 'nonce-inactive',
|
nonce: 'nonce-inactive',
|
||||||
state: inactiveState,
|
state: inactiveState,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -769,7 +769,7 @@ describe('Auth recovery and OIDC logout hardening (e2e)', () => {
|
|||||||
url: '/api/v1/auth/logout',
|
url: '/api/v1/auth/logout',
|
||||||
headers: {
|
headers: {
|
||||||
cookie: cookieHeader(session.jar),
|
cookie: cookieHeader(session.jar),
|
||||||
origin: 'http://localhost:5173',
|
origin: 'http://localhost:6263',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(logoutResponse.statusCode).toBe(200);
|
expect(logoutResponse.statusCode).toBe(200);
|
||||||
|
|||||||
Reference in New Issue
Block a user