Files
hedgedoc/backend/test/test-setup.ts
T
Erik Michelson 6c5b44eaec feat(media): serve uploads from local filesystem with permission check
Previously, the permission checks of the /media/:uuid route could be
bypassed for the local filesystem as a media backend, because the
/media/:uuid route performed a redirect to the target after checking
the permissions. Since the target was always /uploads/:uuid.ext where
ext was the extension of the uploaded file, you could simply try to
guess the file extension and have access to the file since the
/uploads endpoint was simply the complete uploads folder mounted.
Now, media uploads from the local filesystem backend are served
under the /media route directly instead of a redirect.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
2026-07-24 22:17:00 +02:00

556 lines
20 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { HttpServer } from '@nestjs/common';
import { AliasModule } from '../src/alias/alias.module';
import { AliasService } from '../src/alias/alias.service';
import { ApiTokenModule } from '../src/api-token/api-token.module';
import { ApiTokenService } from '../src/api-token/api-token.service';
import { CsrfGuard } from '../src/api/private/csrf/csrf.guard';
import { PrivateApiModule } from '../src/api/private/private-api.module';
import { PublicApiModule } from '../src/api/public/public-api.module';
import { ApiTokenGuard } from '../src/api/utils/guards/api-token.guard';
import { MockApiTokenGuard } from '../src/api/utils/guards/mock-api-token.guard';
import { setupApp } from '../src/app-init';
import { PRIVATE_API_PREFIX, PUBLIC_API_PREFIX } from '../src/app.module';
import { AuthModule } from '../src/auth/auth.module';
import { IdentityService } from '../src/auth/identity.service';
import { LdapService } from '../src/auth/ldap/ldap.service';
import { LocalService } from '../src/auth/local/local.service';
import { OidcService } from '../src/auth/oidc/oidc.service';
import { AppConfig } from '../src/config/app.config';
import { AuthConfig } from '../src/config/auth.config';
import { CustomizationConfig } from '../src/config/customization.config';
import { DatabaseConfig } from '../src/config/database.config';
import { ExternalServicesConfig } from '../src/config/external-services.config';
import { MediaConfig } from '../src/config/media.config';
import { createDefaultMockAppConfig, registerAppConfig } from '../src/config/mock/app.config.mock';
import {
createDefaultMockAuthConfig,
registerAuthConfig,
} from '../src/config/mock/auth.config.mock';
import {
createDefaultMockCustomizationConfig,
registerCustomizationConfig,
} from '../src/config/mock/customization.config.mock';
import {
createDefaultMockDatabaseConfig,
registerDatabaseConfig,
} from '../src/config/mock/database.config.mock';
import {
createDefaultMockExternalServicesConfig,
registerExternalServiceConfig,
} from '../src/config/mock/external-services.config.mock';
import {
createDefaultMockMediaConfig,
registerMediaConfig,
} from '../src/config/mock/media.config.mock';
import {
createDefaultMockNoteConfig,
registerNoteConfig,
} from '../src/config/mock/note.config.mock';
import {
createDefaultMockSecurityConfig,
registerSecurityConfig,
} from '../src/config/mock/security.config.mock';
import { NoteConfig } from '../src/config/note.config';
import { SecurityConfig } from '../src/config/security.config';
import { ApiTokenWithSecretDto } from '../src/dtos/api-token-with-secret.dto';
import { eventModuleConfig } from '../src/events';
import { ExploreService } from '../src/explore/explore.service';
import { FrontendConfigModule } from '../src/frontend-config/frontend-config.module';
import { GroupsModule } from '../src/groups/groups.module';
import { GroupsService } from '../src/groups/groups.service';
import { ConsoleLoggerService } from '../src/logger/console-logger.service';
import { LoggerModule } from '../src/logger/logger.module';
import { FilesystemBackend } from '../src/media/backends/filesystem-backend';
import { MediaModule } from '../src/media/media.module';
import { MediaRedirectModule } from '../src/media-redirect/media-redirect.module';
import { MediaService } from '../src/media/media.service';
import { MonitoringModule } from '../src/monitoring/monitoring.module';
import { NoteService } from '../src/notes/note.service';
import { PermissionService } from '../src/permissions/permission.service';
import { PermissionsModule } from '../src/permissions/permissions.module';
import { RevisionsModule } from '../src/revisions/revisions.module';
import { RevisionsService } from '../src/revisions/revisions.service';
import { SessionModule } from '../src/sessions/session.module';
import { SessionService } from '../src/sessions/session.service';
import { UsersModule } from '../src/users/users.module';
import { UsersService } from '../src/users/users.service';
import { getCurrentDateTime } from '../src/utils/datetime';
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { SpecialGroup } from '@hedgedoc/database';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE, RouterModule, Routes } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing';
import knex, { Knex } from 'knex';
import { KnexModule } from 'nest-knexjs';
import { ZodSerializerInterceptor, ZodValidationPipe } from 'nestjs-zod';
import { types as pgTypes } from 'pg';
import { v4 as uuidv4 } from 'uuid';
import { extendKnexQueryBuilder } from '../src/database/extend-knex-query-builder';
interface CreateTestSetupParameters {
appConfigMock?: AppConfig;
authConfigMock?: AuthConfig;
customizationConfigMock?: CustomizationConfig;
databaseConfigMock?: DatabaseConfig;
externalServicesConfigMock?: ExternalServicesConfig;
mediaConfigMock?: MediaConfig;
noteConfigMock?: NoteConfig;
securityConfigMock?: SecurityConfig;
}
export class TestSetup {
moduleRef: TestingModule;
app: NestFastifyApplication;
knexInstance: Knex;
usersService: UsersService;
groupService: GroupsService;
configService: ConfigService;
identityService: IdentityService;
localIdentityService: LocalService;
ldapService: LdapService;
oidcService: OidcService;
notesService: NoteService;
mediaService: MediaService;
aliasService: AliasService;
apiTokenService: ApiTokenService;
sessionService: SessionService;
revisionsService: RevisionsService;
exploreService: ExploreService;
userIds: number[] = [];
guestUserId: number;
guestUserUuid: string;
authTokens: ApiTokenWithSecretDto[] = [];
anonymousNoteIds: number[] = [];
ownedNoteIds: number[] = [];
permissionsService: PermissionService;
/**
* Initializes the Fastify application for testing and waits for it to be ready.
* Must be called after build() and before making HTTP requests.
*/
public async init(): Promise<void> {
await this.app.init();
await this.app.getHttpAdapter().getInstance().ready();
}
/**
* Cleans up Fastify, NestJS, and the database from a test run.
*/
public async cleanup(): Promise<void> {
this.app.getHttpServer().closeAllConnections();
await this.app.close();
if (this.knexInstance) {
await this.knexInstance.destroy();
}
}
}
/**
* Builder class for TestSetup
* Should be instantiated with the create() method
* The usable TestSetup is generated using build()
*/
export class TestSetupBuilder {
// list of functions that should be executed before or after building the TestingModule
private setupPreCompile: (() => Promise<void>)[] = [];
private setupPostCompile: (() => Promise<void>)[] = [];
private testingModuleBuilder: TestingModuleBuilder;
private testSetup = new TestSetup();
private testId: string;
/**
* Prepares a test database
* @param dbName The name of the database to use
*/
private static async createTestDatabase(dbName: string) {
const dbType = process.env.HEDGEDOC_TEST_DB_TYPE || 'sqlite';
// we need to use this low-level way of writing to get the message to the output without getting
// a lot of extra output about using console.log
process.stdout.write(`Using dbname ${dbName} (${dbType})\n`);
extendKnexQueryBuilder();
if (dbType === 'sqlite') {
return;
}
const knexConfig = this.getTestDatabaseConfig(dbName, true);
const adminKnex = knex(knexConfig);
await adminKnex.raw(`DROP DATABASE IF EXISTS ${dbName}`);
await adminKnex.raw(`CREATE DATABASE ${dbName}`);
if (dbType === 'mariadb') {
await adminKnex.raw(`GRANT ALL PRIVILEGES ON ${dbName}.* TO 'hedgedoc'@'%'`);
}
await adminKnex.destroy();
}
/**
* Returns the database configuration for the test database
*
* @param dbName The name of the database to use
* @param asAdmin If the database should be connected to as the admin user
* @returns The database configuration
*/
private static getTestDatabaseConfig(dbName: string, asAdmin = false): Knex.Config {
const dbType = process.env.HEDGEDOC_TEST_DB_TYPE || 'sqlite';
switch (dbType) {
case 'sqlite':
return {
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
};
case 'postgres':
pgTypes.setTypeParser(pgTypes.builtins.TIMESTAMP, (value: string) => value);
pgTypes.setTypeParser(pgTypes.builtins.TIMESTAMPTZ, (value: string) => value);
return {
client: 'pg',
connection: {
database: asAdmin ? 'postgres' : dbName,
user: 'hedgedoc',
password: 'hedgedoc',
host: process.env.HD_DATABASE_HOST || 'localhost',
port: parseInt(process.env.HD_DATABASE_PORT || '5432'),
// oxlint-disable-next-line @typescript-eslint/naming-convention
application_name: 'HedgeDoc Test Server',
},
};
case 'mariadb':
return {
client: 'mysql2',
connection: {
database: asAdmin ? 'hedgedoc' : dbName,
user: asAdmin ? 'root' : 'hedgedoc',
password: 'hedgedoc',
host: process.env.HD_DATABASE_HOST || 'localhost',
port: parseInt(process.env.HD_DATABASE_PORT || '3306'),
dateStrings: true,
charset: 'utf8mb4',
},
};
default:
throw new Error('Unknown database type in HEDGEDOC_TEST_DB_TYPE');
}
}
/**
* Creates a new instance of TestSetupBuilder
*/
public static create(mocks?: CreateTestSetupParameters): TestSetupBuilder {
const testSetupBuilder = new TestSetupBuilder();
const testId = `hedgedoc_test_${uuidv4()}`.replaceAll('-', '_');
testSetupBuilder.testId = testId;
const routes: Routes = [
{
path: PUBLIC_API_PREFIX,
module: PublicApiModule,
},
{
path: PRIVATE_API_PREFIX,
module: PrivateApiModule,
},
{
path: '/media',
module: MediaRedirectModule,
},
];
process.env.HD_BASE_URL = `https://${testId}.example.com`;
const knexConfig = TestSetupBuilder.getTestDatabaseConfig(testId);
testSetupBuilder.testingModuleBuilder = Test.createTestingModule({
imports: [
RouterModule.register(routes),
ConfigModule.forRoot({
isGlobal: true,
load: [
registerAppConfig(mocks?.appConfigMock ?? createDefaultMockAppConfig()),
registerAuthConfig(mocks?.authConfigMock ?? createDefaultMockAuthConfig()),
registerCustomizationConfig(
mocks?.customizationConfigMock ?? createDefaultMockCustomizationConfig(),
),
registerDatabaseConfig(mocks?.databaseConfigMock ?? createDefaultMockDatabaseConfig()),
registerExternalServiceConfig(
mocks?.externalServicesConfigMock ?? createDefaultMockExternalServicesConfig(),
),
registerMediaConfig(mocks?.mediaConfigMock ?? createDefaultMockMediaConfig()),
registerNoteConfig(mocks?.noteConfigMock ?? createDefaultMockNoteConfig()),
registerSecurityConfig(mocks?.securityConfigMock ?? createDefaultMockSecurityConfig()),
],
}),
KnexModule.forRoot({
config: {
...knexConfig,
migrations: {
directory: 'src/database/migrations/',
},
},
}),
AliasModule,
UsersModule,
RevisionsModule,
PublicApiModule,
PrivateApiModule,
MonitoringModule,
PermissionsModule,
GroupsModule,
LoggerModule,
MediaModule,
ApiTokenModule,
FrontendConfigModule,
AuthModule,
SessionModule,
MediaRedirectModule,
EventEmitterModule.forRoot(eventModuleConfig),
],
providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
{
provide: APP_INTERCEPTOR,
useClass: ZodSerializerInterceptor,
},
{
provide: APP_GUARD,
useClass: CsrfGuard,
},
],
});
return testSetupBuilder;
}
/**
* Builds the final TestSetup from the configured builder
*/
public async build(): Promise<TestSetup> {
console.log(`Calling build with ${this.testId}...`);
await TestSetupBuilder.createTestDatabase(this.testId);
for (const setupFunction of this.setupPreCompile) {
await setupFunction();
}
this.testSetup.moduleRef = await this.testingModuleBuilder.compile();
this.testSetup.usersService = this.testSetup.moduleRef.get<UsersService>(UsersService);
this.testSetup.groupService = this.testSetup.moduleRef.get<GroupsService>(GroupsService);
this.testSetup.configService = this.testSetup.moduleRef.get<ConfigService>(ConfigService);
this.testSetup.identityService = this.testSetup.moduleRef.get<IdentityService>(IdentityService);
this.testSetup.localIdentityService = this.testSetup.moduleRef.get<LocalService>(LocalService);
this.testSetup.notesService = this.testSetup.moduleRef.get<NoteService>(NoteService);
const filesystemBackend = this.testSetup.moduleRef.get<FilesystemBackend>(FilesystemBackend);
this.testSetup.mediaService = this.testSetup.moduleRef.get<MediaService>(MediaService);
this.testSetup.mediaService.mediaBackend = filesystemBackend;
this.testSetup.aliasService = this.testSetup.moduleRef.get<AliasService>(AliasService);
this.testSetup.apiTokenService = this.testSetup.moduleRef.get<ApiTokenService>(ApiTokenService);
this.testSetup.permissionsService =
this.testSetup.moduleRef.get<PermissionService>(PermissionService);
this.testSetup.sessionService = this.testSetup.moduleRef.get<SessionService>(SessionService);
this.testSetup.revisionsService =
this.testSetup.moduleRef.get<RevisionsService>(RevisionsService);
this.testSetup.ldapService = this.testSetup.moduleRef.get<LdapService>(LdapService);
this.testSetup.oidcService = this.testSetup.moduleRef.get<OidcService>(OidcService);
this.testSetup.exploreService = this.testSetup.moduleRef.get<ExploreService>(ExploreService);
this.testSetup.app = this.testSetup.moduleRef.createNestApplication(
new FastifyAdapter({ ignoreTrailingSlash: true }) as HttpServer,
) as NestFastifyApplication;
const appConfig = this.testSetup.configService.get<AppConfig>('appConfig');
const authConfig = this.testSetup.configService.get<AuthConfig>('authConfig');
const mediaConfig = this.testSetup.configService.get<MediaConfig>('mediaConfig');
const securityConfig = this.testSetup.configService.get<SecurityConfig>('securityConfig');
if (!appConfig || !authConfig || !mediaConfig || !securityConfig) {
throw new Error('Could not initialize config in test setup');
}
await setupApp(
this.testSetup.app,
appConfig,
authConfig,
mediaConfig,
securityConfig,
await this.testSetup.app.resolve(ConsoleLoggerService),
);
for (const setupFunction of this.setupPostCompile) {
await setupFunction();
}
return this.testSetup;
}
/**
* Enable mock authentication for the public API
*/
public withMockAuth() {
this.setupPreCompile.push(() => {
this.testingModuleBuilder.overrideGuard(ApiTokenGuard).useClass(MockApiTokenGuard);
return Promise.resolve();
});
return this;
}
/**
* Generate a few users, identities and auth tokens for testing
*/
public withUsers() {
this.setupPostCompile.push(async () => {
// Create users
this.testSetup.userIds.push(
await this.testSetup.localIdentityService.createUserWithLocalIdentity(
username1,
password1,
displayName1,
),
);
this.testSetup.userIds.push(
await this.testSetup.localIdentityService.createUserWithLocalIdentity(
username2,
password2,
displayName2,
),
);
this.testSetup.userIds.push(
await this.testSetup.localIdentityService.createUserWithLocalIdentity(
username3,
password3,
displayName3,
),
);
// create auth tokens
this.testSetup.authTokens = await Promise.all(
this.testSetup.userIds.map(async (userId) => {
// Token is valid for 1 hour
const validUntil = getCurrentDateTime().plus({
hour: 1,
});
return await this.testSetup.apiTokenService.createToken(userId, 'test', validUntil);
}),
);
// create notes owned by the test users
this.testSetup.ownedNoteIds.push(
await this.testSetup.notesService.createNote(
noteContent1,
this.testSetup.userIds[0],
noteAlias1,
),
);
this.testSetup.ownedNoteIds.push(
await this.testSetup.notesService.createNote(
noteContent2,
this.testSetup.userIds[1],
noteAlias2,
),
);
this.testSetup.ownedNoteIds.push(
await this.testSetup.notesService.createNote(
noteContent3,
this.testSetup.userIds[2],
noteAlias3,
),
);
this.testSetup.ownedNoteIds.push(
await this.testSetup.notesService.createNote(
noteContent4,
this.testSetup.userIds[0],
noteAlias4,
),
);
// Alter permissions
await this.testSetup.permissionsService.changePubliclyVisible(
this.testSetup.ownedNoteIds[1],
true,
);
await this.testSetup.permissionsService.changePubliclyVisible(
this.testSetup.ownedNoteIds[2],
true,
);
await this.testSetup.permissionsService.removeGroupPermission(
this.testSetup.ownedNoteIds[3],
await this.testSetup.groupService.getGroupIdByName(SpecialGroup.EVERYONE),
);
await this.testSetup.permissionsService.removeGroupPermission(
this.testSetup.ownedNoteIds[3],
await this.testSetup.groupService.getGroupIdByName(SpecialGroup.LOGGED_IN),
);
});
return this;
}
/**
* Generate a few anonymous notes for testing
*/
public withNotes(): TestSetupBuilder {
this.setupPostCompile.push(async () => {
[this.testSetup.guestUserUuid, this.testSetup.guestUserId] =
await this.testSetup.usersService.createGuestUser();
this.testSetup.anonymousNoteIds.push(
await this.testSetup.notesService.createNote(
anonymousNoteContent1,
this.testSetup.guestUserId,
anonymousNoteAlias1,
),
);
this.testSetup.anonymousNoteIds.push(
await this.testSetup.notesService.createNote(
anonymousNoteContent2,
this.testSetup.guestUserId,
anonymousNoteAlias2,
),
);
this.testSetup.anonymousNoteIds.push(
await this.testSetup.notesService.createNote(
anonymousNoteContent3,
this.testSetup.guestUserId,
anonymousNoteAlias3,
),
);
});
return this;
}
}
export const username1 = 'TestUser1';
export const password1 = 'AStrongP@sswordForUser1';
export const displayName1 = 'Test User 1';
export const username2 = 'TestUser2';
export const password2 = 'AStrongP@sswordForUser2';
export const displayName2 = 'Test User 2';
export const username3 = 'TestUser3';
export const password3 = 'AStrongP@sswordForUser3';
export const displayName3 = 'Test User 3';
export const anonymousNoteAlias1 = 'anonymousNoteId1';
export const anonymousNoteContent1 = 'Anonymous Note 1';
export const anonymousNoteAlias2 = 'anonymousNoteId2';
export const anonymousNoteContent2 = 'Anonymous Note 2';
export const anonymousNoteAlias3 = 'anonymousNoteId3';
export const anonymousNoteContent3 = 'Anonymous Note 3';
export const noteAlias1 = 'testAlias1';
export const noteContent1 = 'Test Note 1';
export const noteAlias2 = 'testAlias2';
export const noteContent2 = '# Test Note 2';
export const noteAlias3 = 'testAlias3';
export const noteContent3 = '---\ntype: slide\n---\nTest Note 3';
export const noteAlias4 = 'testAlias4';
export const noteContent4 = 'Test Note 4';