Merge pull request #1207 from hedgedoc/maintenance/ts_strict_mode

This commit is contained in:
David Mehren 2021-05-09 21:23:59 +02:00 committed by GitHub
commit c9d50cb60c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 329 additions and 132 deletions

View file

@ -13,5 +13,10 @@
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"coverageDirectory": "./coverage-e2e", "coverageDirectory": "./coverage-e2e",
"testTimeout": 10000 "testTimeout": 10000,
"globals": {
"ts-jest": {
"tsconfig": "tsconfig.test.json"
}
}
} }

View file

@ -98,6 +98,11 @@
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node",
"globals": {
"ts-jest": {
"tsconfig": "tsconfig.test.json"
}
}
} }
} }

View file

@ -15,6 +15,7 @@ import {
Put, Put,
UseGuards, UseGuards,
Req, Req,
InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { HistoryEntryUpdateDto } from '../../../history/history-entry-update.dto'; import { HistoryEntryUpdateDto } from '../../../history/history-entry-update.dto';
import { HistoryService } from '../../../history/history.service'; import { HistoryService } from '../../../history/history.service';
@ -65,6 +66,10 @@ export class MeController {
}) })
@ApiUnauthorizedResponse({ description: unauthorizedDescription }) @ApiUnauthorizedResponse({ description: unauthorizedDescription })
async getMe(@Req() req: Request): Promise<UserInfoDto> { async getMe(@Req() req: Request): Promise<UserInfoDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
return this.usersService.toUserDto( return this.usersService.toUserDto(
await this.usersService.getUserByUsername(req.user.userName), await this.usersService.getUserByUsername(req.user.userName),
); );
@ -79,6 +84,10 @@ export class MeController {
}) })
@ApiUnauthorizedResponse({ description: unauthorizedDescription }) @ApiUnauthorizedResponse({ description: unauthorizedDescription })
async getUserHistory(@Req() req: Request): Promise<HistoryEntryDto[]> { async getUserHistory(@Req() req: Request): Promise<HistoryEntryDto[]> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
const foundEntries = await this.historyService.getEntriesByUser(req.user); const foundEntries = await this.historyService.getEntriesByUser(req.user);
return await Promise.all( return await Promise.all(
foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)), foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)),
@ -97,6 +106,10 @@ export class MeController {
@Req() req: Request, @Req() req: Request,
@Param('note') note: string, @Param('note') note: string,
): Promise<HistoryEntryDto> { ): Promise<HistoryEntryDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
try { try {
const foundEntry = await this.historyService.getEntryByNoteIdOrAlias( const foundEntry = await this.historyService.getEntryByNoteIdOrAlias(
note, note,
@ -124,6 +137,10 @@ export class MeController {
@Param('note') note: string, @Param('note') note: string,
@Body() entryUpdateDto: HistoryEntryUpdateDto, @Body() entryUpdateDto: HistoryEntryUpdateDto,
): Promise<HistoryEntryDto> { ): Promise<HistoryEntryDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
// ToDo: Check if user is allowed to pin this history entry // ToDo: Check if user is allowed to pin this history entry
try { try {
return this.historyService.toHistoryEntryDto( return this.historyService.toHistoryEntryDto(
@ -151,6 +168,10 @@ export class MeController {
@Req() req: Request, @Req() req: Request,
@Param('note') note: string, @Param('note') note: string,
): Promise<void> { ): Promise<void> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
// ToDo: Check if user is allowed to delete note // ToDo: Check if user is allowed to delete note
try { try {
await this.historyService.deleteHistoryEntry(note, req.user); await this.historyService.deleteHistoryEntry(note, req.user);
@ -171,6 +192,10 @@ export class MeController {
}) })
@ApiUnauthorizedResponse({ description: unauthorizedDescription }) @ApiUnauthorizedResponse({ description: unauthorizedDescription })
async getMyNotes(@Req() req: Request): Promise<NoteMetadataDto[]> { async getMyNotes(@Req() req: Request): Promise<NoteMetadataDto[]> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
const notes = this.notesService.getUserNotes(req.user); const notes = this.notesService.getUserNotes(req.user);
return await Promise.all( return await Promise.all(
(await notes).map((note) => this.notesService.toNoteMetadataDto(note)), (await notes).map((note) => this.notesService.toNoteMetadataDto(note)),
@ -186,6 +211,10 @@ export class MeController {
}) })
@ApiUnauthorizedResponse({ description: unauthorizedDescription }) @ApiUnauthorizedResponse({ description: unauthorizedDescription })
async getMyMedia(@Req() req: Request): Promise<MediaUploadDto[]> { async getMyMedia(@Req() req: Request): Promise<MediaUploadDto[]> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
const media = await this.mediaService.listUploadsByUser(req.user); const media = await this.mediaService.listUploadsByUser(req.user);
return media.map((media) => this.mediaService.toMediaUploadDto(media)); return media.map((media) => this.mediaService.toMediaUploadDto(media));
} }

View file

@ -94,6 +94,10 @@ export class MediaController {
@UploadedFile() file: MulterFile, @UploadedFile() file: MulterFile,
@Headers('HedgeDoc-Note') noteId: string, @Headers('HedgeDoc-Note') noteId: string,
): Promise<MediaUploadUrlDto> { ): Promise<MediaUploadUrlDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
const username = req.user.userName; const username = req.user.userName;
this.logger.debug( this.logger.debug(
`Recieved filename '${file.originalname}' for note '${noteId}' from user '${username}'`, `Recieved filename '${file.originalname}' for note '${noteId}' from user '${username}'`,
@ -130,6 +134,10 @@ export class MediaController {
@Req() req: Request, @Req() req: Request,
@Param('filename') filename: string, @Param('filename') filename: string,
): Promise<void> { ): Promise<void> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
const username = req.user.userName; const username = req.user.userName;
try { try {
this.logger.debug( this.logger.debug(

View file

@ -12,6 +12,7 @@ import {
Get, Get,
Header, Header,
HttpCode, HttpCode,
InternalServerErrorException,
NotFoundException, NotFoundException,
Param, Param,
Post, Post,
@ -88,6 +89,10 @@ export class NotesController {
@Req() req: Request, @Req() req: Request,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
// ToDo: provide user for createNoteDto // ToDo: provide user for createNoteDto
if (!this.permissionsService.mayCreate(req.user)) { if (!this.permissionsService.mayCreate(req.user)) {
throw new UnauthorizedException('Creating note denied!'); throw new UnauthorizedException('Creating note denied!');
@ -111,6 +116,10 @@ export class NotesController {
@Req() req: Request, @Req() req: Request,
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
let note: Note; let note: Note;
try { try {
note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
@ -144,6 +153,10 @@ export class NotesController {
@Param('noteAlias') noteAlias: string, @Param('noteAlias') noteAlias: string,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
if (!this.permissionsService.mayCreate(req.user)) { if (!this.permissionsService.mayCreate(req.user)) {
throw new UnauthorizedException('Creating note denied!'); throw new UnauthorizedException('Creating note denied!');
} }
@ -175,6 +188,10 @@ export class NotesController {
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
@Body() noteMediaDeletionDto: NoteMediaDeletionDto, @Body() noteMediaDeletionDto: NoteMediaDeletionDto,
): Promise<void> { ): Promise<void> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.isOwner(req.user, note)) { if (!this.permissionsService.isOwner(req.user, note)) {
@ -217,6 +234,10 @@ export class NotesController {
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayWrite(req.user, note)) { if (!this.permissionsService.mayWrite(req.user, note)) {
@ -251,6 +272,10 @@ export class NotesController {
@Req() req: Request, @Req() req: Request,
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<string> { ): Promise<string> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayRead(req.user, note)) { if (!this.permissionsService.mayRead(req.user, note)) {
@ -281,6 +306,10 @@ export class NotesController {
@Req() req: Request, @Req() req: Request,
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<NoteMetadataDto> { ): Promise<NoteMetadataDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayRead(req.user, note)) { if (!this.permissionsService.mayRead(req.user, note)) {
@ -315,6 +344,10 @@ export class NotesController {
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
@Body() updateDto: NotePermissionsUpdateDto, @Body() updateDto: NotePermissionsUpdateDto,
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.isOwner(req.user, note)) { if (!this.permissionsService.isOwner(req.user, note)) {
@ -348,6 +381,10 @@ export class NotesController {
@Req() req: Request, @Req() req: Request,
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<RevisionMetadataDto[]> { ): Promise<RevisionMetadataDto[]> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayRead(req.user, note)) { if (!this.permissionsService.mayRead(req.user, note)) {
@ -384,6 +421,10 @@ export class NotesController {
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
@Param('revisionId') revisionId: number, @Param('revisionId') revisionId: number,
): Promise<RevisionDto> { ): Promise<RevisionDto> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayRead(req.user, note)) { if (!this.permissionsService.mayRead(req.user, note)) {
@ -415,6 +456,10 @@ export class NotesController {
@Req() req: Request, @Req() req: Request,
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<MediaUploadDto[]> { ): Promise<MediaUploadDto[]> {
if (!req.user) {
// We should never reach this, as the TokenAuthGuard handles missing user info
throw new InternalServerErrorException('Request did not specify user');
}
try { try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias); const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
if (!this.permissionsService.mayRead(req.user, note)) { if (!this.permissionsService.mayRead(req.user, note)) {

View file

@ -42,15 +42,20 @@ export const MarkdownBody = createParamDecorator(
}, },
[ [
(target, key): void => { (target, key): void => {
ApiConsumes('text/markdown')( const ownPropertyDescriptor = Object.getOwnPropertyDescriptor(
target, target,
key, key,
Object.getOwnPropertyDescriptor(target, key),
); );
if (!ownPropertyDescriptor) {
throw new Error(
`Could not get property descriptor for target ${target.toString()} and key ${key.toString()}`,
);
}
ApiConsumes('text/markdown')(target, key, ownPropertyDescriptor);
ApiBody({ ApiBody({
required: true, required: true,
schema: { example: '# Markdown Body' }, schema: { example: '# Markdown Body' },
})(target, key, Object.getOwnPropertyDescriptor(target, key)); })(target, key, ownPropertyDescriptor);
}, },
], ],
); );

View file

@ -15,8 +15,8 @@ export class AuthTokenDto {
createdAt: Date; createdAt: Date;
@IsDate() @IsDate()
@IsOptional() @IsOptional()
validUntil: Date; validUntil: Date | null;
@IsDate() @IsDate()
@IsOptional() @IsOptional()
lastUsed: Date; lastUsed: Date | null;
} }

View file

@ -37,13 +37,15 @@ export class AuthToken {
@Column({ @Column({
nullable: true, nullable: true,
type: 'date',
}) })
validUntil: Date; validUntil: Date | null;
@Column({ @Column({
nullable: true, nullable: true,
type: 'date',
}) })
lastUsed: Date; lastUsed: Date | null;
public static create( public static create(
user: User, user: User,
@ -62,6 +64,7 @@ export class AuthToken {
newToken.accessTokenHash = accessToken; newToken.accessTokenHash = accessToken;
newToken.createdAt = new Date(); newToken.createdAt = new Date();
newToken.validUntil = validUntil; newToken.validUntil = validUntil;
newToken.lastUsed = null;
return newToken; return newToken;
} }
} }

View file

@ -168,6 +168,12 @@ describe('AuthService', () => {
); );
await service.setLastUsedToken(authToken.keyId); await service.setLastUsedToken(authToken.keyId);
}); });
it('throws if the token is not in the database', async () => {
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(undefined);
await expect(service.setLastUsedToken(authToken.keyId)).rejects.toThrow(
NotInDBError,
);
});
}); });
describe('validateToken', () => { describe('validateToken', () => {
@ -227,6 +233,12 @@ describe('AuthService', () => {
); );
await service.removeToken(authToken.keyId); await service.removeToken(authToken.keyId);
}); });
it('throws if the token is not in the database', async () => {
jest.spyOn(authTokenRepo, 'findOne').mockResolvedValueOnce(undefined);
await expect(service.removeToken(authToken.keyId)).rejects.toThrow(
NotInDBError,
);
});
}); });
describe('createTokenForUser', () => { describe('createTokenForUser', () => {
@ -239,7 +251,7 @@ describe('AuthService', () => {
}); });
jest.spyOn(authTokenRepo, 'save').mockImplementationOnce( jest.spyOn(authTokenRepo, 'save').mockImplementationOnce(
async (authTokenSaved: AuthToken, _): Promise<AuthToken> => { async (authTokenSaved: AuthToken, _): Promise<AuthToken> => {
expect(authTokenSaved.lastUsed).toBeUndefined(); expect(authTokenSaved.lastUsed).toBeNull();
return authTokenSaved; return authTokenSaved;
}, },
); );
@ -263,7 +275,7 @@ describe('AuthService', () => {
}); });
jest.spyOn(authTokenRepo, 'save').mockImplementationOnce( jest.spyOn(authTokenRepo, 'save').mockImplementationOnce(
async (authTokenSaved: AuthToken, _): Promise<AuthToken> => { async (authTokenSaved: AuthToken, _): Promise<AuthToken> => {
expect(authTokenSaved.lastUsed).toBeUndefined(); expect(authTokenSaved.lastUsed).toBeNull();
return authTokenSaved; return authTokenSaved;
}, },
); );
@ -307,4 +319,10 @@ describe('AuthService', () => {
); );
}); });
}); });
describe('randomString', () => {
it('throws on invalid lenght parameter', () => {
expect(() => service.randomString(0)).toThrow();
expect(() => service.randomString(-1)).toThrow();
});
});
}); });

View file

@ -64,7 +64,7 @@ export class AuthService {
randomString(length: number): Buffer { randomString(length: number): Buffer {
if (length <= 0) { if (length <= 0) {
return null; throw new Error('randomString cannot have a length < 1');
} }
return randomBytes(length); return randomBytes(length);
} }
@ -127,6 +127,9 @@ export class AuthService {
const accessToken = await this.authTokenRepository.findOne({ const accessToken = await this.authTokenRepository.findOne({
where: { keyId: keyId }, where: { keyId: keyId },
}); });
if (accessToken === undefined) {
throw new NotInDBError(`AuthToken for key '${keyId}' not found`);
}
accessToken.lastUsed = new Date(); accessToken.lastUsed = new Date();
await this.authTokenRepository.save(accessToken); await this.authTokenRepository.save(accessToken);
} }
@ -170,23 +173,19 @@ export class AuthService {
const token = await this.authTokenRepository.findOne({ const token = await this.authTokenRepository.findOne({
where: { keyId: keyId }, where: { keyId: keyId },
}); });
if (token === undefined) {
throw new NotInDBError(`AuthToken for key '${keyId}' not found`);
}
await this.authTokenRepository.remove(token); await this.authTokenRepository.remove(token);
} }
toAuthTokenDto(authToken: AuthToken): AuthTokenDto | null { toAuthTokenDto(authToken: AuthToken): AuthTokenDto {
if (!authToken) {
this.logger.warn(
`Recieved ${String(authToken)} argument!`,
'toAuthTokenDto',
);
return null;
}
const tokenDto: AuthTokenDto = { const tokenDto: AuthTokenDto = {
lastUsed: null,
validUntil: null,
label: authToken.label, label: authToken.label,
keyId: authToken.keyId, keyId: authToken.keyId,
createdAt: authToken.createdAt, createdAt: authToken.createdAt,
validUntil: null,
lastUsed: null,
}; };
if (authToken.validUntil) { if (authToken.validUntil) {
@ -201,9 +200,9 @@ export class AuthService {
} }
toAuthTokenWithSecretDto( toAuthTokenWithSecretDto(
authToken: AuthToken | null | undefined, authToken: AuthToken,
secret: string, secret: string,
): AuthTokenWithSecretDto | null { ): AuthTokenWithSecretDto {
const tokenDto = this.toAuthTokenDto(authToken); const tokenDto = this.toAuthTokenDto(authToken);
return { return {
...tokenDto, ...tokenDto,

View file

@ -7,7 +7,7 @@
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import * as Joi from 'joi'; import * as Joi from 'joi';
import { Loglevel } from './loglevel.enum'; import { Loglevel } from './loglevel.enum';
import { buildErrorMessage, toArrayConfig } from './utils'; import { buildErrorMessage, parseOptionalInt, toArrayConfig } from './utils';
export interface AppConfig { export interface AppConfig {
domain: string; domain: string;
@ -46,11 +46,10 @@ export default registerAs('appConfig', () => {
{ {
domain: process.env.HD_DOMAIN, domain: process.env.HD_DOMAIN,
rendererOrigin: process.env.HD_RENDERER_ORIGIN, rendererOrigin: process.env.HD_RENDERER_ORIGIN,
port: parseInt(process.env.PORT) || undefined, port: parseOptionalInt(process.env.PORT),
loglevel: process.env.HD_LOGLEVEL, loglevel: process.env.HD_LOGLEVEL,
forbiddenNoteIds: toArrayConfig(process.env.HD_FORBIDDEN_NOTE_IDS, ','), forbiddenNoteIds: toArrayConfig(process.env.HD_FORBIDDEN_NOTE_IDS, ','),
maxDocumentLength: maxDocumentLength: parseOptionalInt(process.env.HD_MAX_DOCUMENT_LENGTH),
parseInt(process.env.HD_MAX_DOCUMENT_LENGTH) || undefined,
}, },
{ {
abortEarly: false, abortEarly: false,

View file

@ -7,7 +7,7 @@
import * as Joi from 'joi'; import * as Joi from 'joi';
import { DatabaseDialect } from './database-dialect.enum'; import { DatabaseDialect } from './database-dialect.enum';
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import { buildErrorMessage } from './utils'; import { buildErrorMessage, parseOptionalInt } from './utils';
export interface DatabaseConfig { export interface DatabaseConfig {
username: string; username: string;
@ -62,7 +62,7 @@ export default registerAs('databaseConfig', () => {
password: process.env.HD_DATABASE_PASS, password: process.env.HD_DATABASE_PASS,
database: process.env.HD_DATABASE_NAME, database: process.env.HD_DATABASE_NAME,
host: process.env.HD_DATABASE_HOST, host: process.env.HD_DATABASE_HOST,
port: parseInt(process.env.HD_DATABASE_PORT) || undefined, port: parseOptionalInt(process.env.HD_DATABASE_PORT),
storage: process.env.HD_DATABASE_STORAGE, storage: process.env.HD_DATABASE_STORAGE,
dialect: process.env.HD_DATABASE_DIALECT, dialect: process.env.HD_DATABASE_DIALECT,
}, },

View file

@ -6,7 +6,7 @@
import * as Joi from 'joi'; import * as Joi from 'joi';
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import { buildErrorMessage } from './utils'; import { buildErrorMessage, parseOptionalInt } from './utils';
export interface HstsConfig { export interface HstsConfig {
enable: boolean; enable: boolean;
@ -32,7 +32,7 @@ export default registerAs('hstsConfig', () => {
const hstsConfig = hstsSchema.validate( const hstsConfig = hstsSchema.validate(
{ {
enable: process.env.HD_HSTS_ENABLE, enable: process.env.HD_HSTS_ENABLE,
maxAgeSeconds: parseInt(process.env.HD_HSTS_MAX_AGE) || undefined, maxAgeSeconds: parseOptionalInt(process.env.HD_HSTS_MAX_AGE),
includeSubdomains: process.env.HD_HSTS_INCLUDE_SUBDOMAINS, includeSubdomains: process.env.HD_HSTS_INCLUDE_SUBDOMAINS,
preload: process.env.HD_HSTS_PRELOAD, preload: process.env.HD_HSTS_PRELOAD,
}, },

View file

@ -6,6 +6,7 @@
import { import {
needToLog, needToLog,
parseOptionalInt,
replaceAuthErrorsWithEnvironmentVariables, replaceAuthErrorsWithEnvironmentVariables,
toArrayConfig, toArrayConfig,
} from './utils'; } from './utils';
@ -84,4 +85,12 @@ describe('config utils', () => {
expect(needToLog(currentLevel, Loglevel.TRACE)).toBeTruthy(); expect(needToLog(currentLevel, Loglevel.TRACE)).toBeTruthy();
}); });
}); });
describe('parseOptionalInt', () => {
it('returns undefined on undefined parameter', () => {
expect(parseOptionalInt(undefined)).toEqual(undefined);
});
it('correctly parses a string', () => {
expect(parseOptionalInt('42')).toEqual(42);
});
});
}); });

View file

@ -6,7 +6,7 @@
import { Loglevel } from './loglevel.enum'; import { Loglevel } from './loglevel.enum';
export function toArrayConfig(configValue: string, separator = ','): string[] { export function toArrayConfig(configValue?: string, separator = ','): string[] {
if (!configValue) { if (!configValue) {
return []; return [];
} }
@ -113,3 +113,10 @@ function transformLoglevelToInt(loglevel: Loglevel): number {
return 1; return 1;
} }
} }
export function parseOptionalInt(value?: string): number | undefined {
if (value === undefined) {
return undefined;
}
return parseInt(value);
}

View file

@ -84,7 +84,7 @@ export class BrandingDto {
*/ */
@IsString() @IsString()
@IsOptional() @IsOptional()
name: string; name?: string;
/** /**
* The logo to be displayed next to the HedgeDoc logo * The logo to be displayed next to the HedgeDoc logo
@ -92,7 +92,7 @@ export class BrandingDto {
*/ */
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
logo: URL; logo?: URL;
} }
export class CustomAuthEntry { export class CustomAuthEntry {
@ -148,7 +148,7 @@ export class SpecialUrlsDto {
*/ */
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
privacy: URL; privacy?: URL;
/** /**
* A link to the terms of use * A link to the terms of use
@ -156,7 +156,7 @@ export class SpecialUrlsDto {
*/ */
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
termsOfUse: URL; termsOfUse?: URL;
/** /**
* A link to the imprint * A link to the imprint
@ -164,7 +164,7 @@ export class SpecialUrlsDto {
*/ */
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
imprint: URL; imprint?: URL;
} }
export class IframeCommunicationDto { export class IframeCommunicationDto {
@ -174,7 +174,7 @@ export class IframeCommunicationDto {
*/ */
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
editorOrigin: URL; editorOrigin?: URL;
/** /**
* The origin under which the renderer page will be served * The origin under which the renderer page will be served
@ -182,7 +182,7 @@ export class IframeCommunicationDto {
*/ */
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
rendererOrigin: URL; rendererOrigin?: URL;
} }
export class FrontendConfigDto { export class FrontendConfigDto {
@ -239,7 +239,7 @@ export class FrontendConfigDto {
*/ */
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
plantUmlServer: URL; plantUmlServer?: URL;
/** /**
* The maximal length of each document * The maximal length of each document

View file

@ -69,13 +69,5 @@ describe('GroupsService', () => {
expect(groupDto.name).toEqual(group.name); expect(groupDto.name).toEqual(group.name);
expect(groupDto.special).toBeFalsy(); expect(groupDto.special).toBeFalsy();
}); });
it('fails with null parameter', () => {
const groupDto = service.toGroupDto(null);
expect(groupDto).toBeNull();
});
it('fails with undefined parameter', () => {
const groupDto = service.toGroupDto(undefined);
expect(groupDto).toBeNull();
});
}); });
}); });

View file

@ -43,11 +43,7 @@ export class GroupsService {
* @param {Group} group - the group to use * @param {Group} group - the group to use
* @return {GroupInfoDto} the built GroupInfoDto * @return {GroupInfoDto} the built GroupInfoDto
*/ */
toGroupDto(group: Group | null | undefined): GroupInfoDto | null { toGroupDto(group: Group): GroupInfoDto {
if (!group) {
this.logger.warn(`Recieved ${String(group)} argument!`, 'toGroupDto');
return null;
}
return { return {
name: group.name, name: group.name,
displayName: group.displayName, displayName: group.displayName,

View file

@ -69,13 +69,19 @@ export class HistoryService {
* @return {HistoryEntry} the requested history entry * @return {HistoryEntry} the requested history entry
*/ */
private async getEntryByNote(note: Note, user: User): Promise<HistoryEntry> { private async getEntryByNote(note: Note, user: User): Promise<HistoryEntry> {
return await this.historyEntryRepository.findOne({ const entry = await this.historyEntryRepository.findOne({
where: { where: {
note: note, note: note,
user: user, user: user,
}, },
relations: ['note', 'user'], relations: ['note', 'user'],
}); });
if (!entry) {
throw new NotInDBError(
`User '${user.userName}' has no HistoryEntry for Note with id '${note.id}'`,
);
}
return entry;
} }
/** /**
@ -89,13 +95,17 @@ export class HistoryService {
note: Note, note: Note,
user: User, user: User,
): Promise<HistoryEntry> { ): Promise<HistoryEntry> {
let entry = await this.getEntryByNote(note, user); try {
if (!entry) { const entry = await this.getEntryByNote(note, user);
entry = HistoryEntry.create(user, note);
} else {
entry.updatedAt = new Date(); entry.updatedAt = new Date();
return await this.historyEntryRepository.save(entry);
} catch (e) {
if (e instanceof NotInDBError) {
const entry = HistoryEntry.create(user, note);
return await this.historyEntryRepository.save(entry);
}
throw e;
} }
return await this.historyEntryRepository.save(entry);
} }
/** /**
@ -112,11 +122,6 @@ export class HistoryService {
updateDto: HistoryEntryUpdateDto, updateDto: HistoryEntryUpdateDto,
): Promise<HistoryEntry> { ): Promise<HistoryEntry> {
const entry = await this.getEntryByNoteIdOrAlias(noteIdOrAlias, user); const entry = await this.getEntryByNoteIdOrAlias(noteIdOrAlias, user);
if (!entry) {
throw new NotInDBError(
`User '${user.userName}' has no HistoryEntry for Note with id '${noteIdOrAlias}'`,
);
}
entry.pinStatus = updateDto.pinStatus; entry.pinStatus = updateDto.pinStatus;
return await this.historyEntryRepository.save(entry); return await this.historyEntryRepository.save(entry);
} }
@ -130,11 +135,6 @@ export class HistoryService {
*/ */
async deleteHistoryEntry(noteIdOrAlias: string, user: User): Promise<void> { async deleteHistoryEntry(noteIdOrAlias: string, user: User): Promise<void> {
const entry = await this.getEntryByNoteIdOrAlias(noteIdOrAlias, user); const entry = await this.getEntryByNoteIdOrAlias(noteIdOrAlias, user);
if (!entry) {
throw new NotInDBError(
`User '${user.userName}' has no HistoryEntry for Note with id '${noteIdOrAlias}'`,
);
}
await this.historyEntryRepository.remove(entry); await this.historyEntryRepository.remove(entry);
return; return;
} }

View file

@ -14,7 +14,7 @@ import { needToLog } from '../config/utils';
@Injectable({ scope: Scope.TRANSIENT }) @Injectable({ scope: Scope.TRANSIENT })
export class ConsoleLoggerService { export class ConsoleLoggerService {
private classContext: string; private classContext: string | undefined;
private lastTimestamp: number; private lastTimestamp: number;
constructor( constructor(
@ -83,7 +83,7 @@ export class ConsoleLoggerService {
} }
} }
private makeContextString(functionContext: string): string { private makeContextString(functionContext?: string): string {
let context = this.classContext; let context = this.classContext;
if (!context) { if (!context) {
context = 'HedgeDoc'; context = 'HedgeDoc';

View file

@ -26,6 +26,11 @@ async function bootstrap(): Promise<void> {
const appConfig = configService.get<AppConfig>('appConfig'); const appConfig = configService.get<AppConfig>('appConfig');
const mediaConfig = configService.get<MediaConfig>('mediaConfig'); const mediaConfig = configService.get<MediaConfig>('mediaConfig');
if (!appConfig || !mediaConfig) {
logger.error('Could not initialize config, aborting.', 'AppBootstrap');
process.exit(1);
}
setupPublicApiDocs(app); setupPublicApiDocs(app);
logger.log( logger.log(
`Serving OpenAPI docs for public api under '/apidoc'`, `Serving OpenAPI docs for public api under '/apidoc'`,

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { IsDate, IsString } from 'class-validator'; import { IsDate, IsOptional, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
export class MediaUploadDto { export class MediaUploadDto {
@ -21,8 +21,9 @@ export class MediaUploadDto {
* @example "noteId" TODO how looks a note id? * @example "noteId" TODO how looks a note id?
*/ */
@IsString() @IsString()
@IsOptional()
@ApiProperty() @ApiProperty()
noteId: string; noteId: string | null;
/** /**
* The date when the upload objects was created. * The date when the upload objects was created.

View file

@ -26,7 +26,7 @@ export class MediaUpload {
@ManyToOne((_) => Note, (note) => note.mediaUploads, { @ManyToOne((_) => Note, (note) => note.mediaUploads, {
nullable: true, nullable: true,
}) })
note: Note; note: Note | null;
@ManyToOne((_) => User, (user) => user.mediaUploads, { @ManyToOne((_) => User, (user) => user.mediaUploads, {
nullable: false, nullable: false,
@ -43,8 +43,9 @@ export class MediaUpload {
@Column({ @Column({
nullable: true, nullable: true,
type: 'text',
}) })
backendData: BackendData; backendData: BackendData | null;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;

View file

@ -202,6 +202,10 @@ export class MediaService {
return BackendType.S3; return BackendType.S3;
case 'webdav': case 'webdav':
return BackendType.WEBDAV; return BackendType.WEBDAV;
default:
throw new Error(
`Unexpected media backend ${this.mediaConfig.backend.use}`,
);
} }
} }
@ -223,7 +227,7 @@ export class MediaService {
toMediaUploadDto(mediaUpload: MediaUpload): MediaUploadDto { toMediaUploadDto(mediaUpload: MediaUpload): MediaUploadDto {
return { return {
url: mediaUpload.fileUrl, url: mediaUpload.fileUrl,
noteId: mediaUpload.note.id, noteId: mediaUpload.note?.id ?? null,
createdAt: mediaUpload.createdAt, createdAt: mediaUpload.createdAt,
userName: mediaUpload.user.userName, userName: mediaUpload.user.userName,
}; };

View file

@ -31,7 +31,7 @@ export class NoteMetadataDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
@ApiPropertyOptional() @ApiPropertyOptional()
alias: string; alias: string | null;
/** /**
* Title of the note * Title of the note
@ -72,8 +72,9 @@ export class NoteMetadataDto {
* User that last edited the note * User that last edited the note
*/ */
@ValidateNested() @ValidateNested()
@ApiProperty({ type: UserInfoDto }) @ApiPropertyOptional({ type: UserInfoDto })
updateUser: UserInfoDto; @IsOptional()
updateUser: UserInfoDto | null;
/** /**
* Counts how many times the published note has been viewed * Counts how many times the published note has been viewed

View file

@ -4,10 +4,16 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { IsArray, IsBoolean, IsString, ValidateNested } from 'class-validator'; import {
IsArray,
IsBoolean,
IsOptional,
IsString,
ValidateNested,
} from 'class-validator';
import { UserInfoDto } from '../users/user-info.dto'; import { UserInfoDto } from '../users/user-info.dto';
import { GroupInfoDto } from '../groups/group-info.dto'; import { GroupInfoDto } from '../groups/group-info.dto';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class NoteUserPermissionEntryDto { export class NoteUserPermissionEntryDto {
/** /**
@ -84,8 +90,9 @@ export class NotePermissionsDto {
* User this permission applies to * User this permission applies to
*/ */
@ValidateNested() @ValidateNested()
@ApiProperty({ type: UserInfoDto }) @ApiPropertyOptional({ type: UserInfoDto })
owner: UserInfoDto; @IsOptional()
owner: UserInfoDto | null;
/** /**
* List of users the note is shared with * List of users the note is shared with

View file

@ -35,8 +35,9 @@ export class Note {
@Column({ @Column({
unique: true, unique: true,
nullable: true, nullable: true,
type: 'text',
}) })
alias?: string; alias: string | null;
@OneToMany( @OneToMany(
(_) => NoteGroupPermission, (_) => NoteGroupPermission,
(groupPermission) => groupPermission.note, (groupPermission) => groupPermission.note,
@ -56,8 +57,9 @@ export class Note {
viewCount: number; viewCount: number;
@ManyToOne((_) => User, (user) => user.ownedNotes, { @ManyToOne((_) => User, (user) => user.ownedNotes, {
onDelete: 'CASCADE', // This deletes the Note, when the associated User is deleted onDelete: 'CASCADE', // This deletes the Note, when the associated User is deleted
nullable: true,
}) })
owner: User; owner: User | null;
@OneToMany((_) => Revision, (revision) => revision.note, { cascade: true }) @OneToMany((_) => Revision, (revision) => revision.note, { cascade: true })
revisions: Promise<Revision[]>; revisions: Promise<Revision[]>;
@OneToMany((_) => AuthorColor, (authorColor) => authorColor.note) @OneToMany((_) => AuthorColor, (authorColor) => authorColor.note)
@ -69,12 +71,14 @@ export class Note {
@Column({ @Column({
nullable: true, nullable: true,
type: 'text',
}) })
description?: string; description: string | null;
@Column({ @Column({
nullable: true, nullable: true,
type: 'text',
}) })
title?: string; title: string | null;
@ManyToMany((_) => Tag, (tag) => tag.notes, { eager: true, cascade: true }) @ManyToMany((_) => Tag, (tag) => tag.notes, { eager: true, cascade: true })
@JoinTable() @JoinTable()
@ -89,9 +93,9 @@ export class Note {
} }
const newNote = new Note(); const newNote = new Note();
newNote.shortid = shortid; newNote.shortid = shortid;
newNote.alias = alias; newNote.alias = alias ?? null;
newNote.viewCount = 0; newNote.viewCount = 0;
newNote.owner = owner; newNote.owner = owner ?? null;
newNote.authorColors = []; newNote.authorColors = [];
newNote.userPermissions = []; newNote.userPermissions = [];
newNote.groupPermissions = []; newNote.groupPermissions = [];

View file

@ -152,8 +152,8 @@ describe('NotesService', () => {
expect(newNote.userPermissions).toHaveLength(0); expect(newNote.userPermissions).toHaveLength(0);
expect(newNote.groupPermissions).toHaveLength(0); expect(newNote.groupPermissions).toHaveLength(0);
expect(newNote.tags).toHaveLength(0); expect(newNote.tags).toHaveLength(0);
expect(newNote.owner).toBeUndefined(); expect(newNote.owner).toBeNull();
expect(newNote.alias).toBeUndefined(); expect(newNote.alias).toBeNull();
}); });
it('without alias, with owner', async () => { it('without alias, with owner', async () => {
const newNote = await service.createNote(content, undefined, user); const newNote = await service.createNote(content, undefined, user);
@ -166,7 +166,7 @@ describe('NotesService', () => {
expect(newNote.groupPermissions).toHaveLength(0); expect(newNote.groupPermissions).toHaveLength(0);
expect(newNote.tags).toHaveLength(0); expect(newNote.tags).toHaveLength(0);
expect(newNote.owner).toEqual(user); expect(newNote.owner).toEqual(user);
expect(newNote.alias).toBeUndefined(); expect(newNote.alias).toBeNull();
}); });
it('with alias, without owner', async () => { it('with alias, without owner', async () => {
const newNote = await service.createNote(content, alias); const newNote = await service.createNote(content, alias);
@ -177,7 +177,7 @@ describe('NotesService', () => {
expect(newNote.userPermissions).toHaveLength(0); expect(newNote.userPermissions).toHaveLength(0);
expect(newNote.groupPermissions).toHaveLength(0); expect(newNote.groupPermissions).toHaveLength(0);
expect(newNote.tags).toHaveLength(0); expect(newNote.tags).toHaveLength(0);
expect(newNote.owner).toBeUndefined(); expect(newNote.owner).toBeNull();
expect(newNote.alias).toEqual(alias); expect(newNote.alias).toEqual(alias);
}); });
it('with alias, with owner', async () => { it('with alias, with owner', async () => {

View file

@ -102,14 +102,18 @@ export class NotesService {
} }
try { try {
return await this.noteRepository.save(newNote); return await this.noteRepository.save(newNote);
} catch { } catch (e) {
this.logger.debug( if (alias) {
`A note with the alias '${alias}' already exists.`, this.logger.debug(
'createNote', `A note with the alias '${alias}' already exists.`,
); 'createNote',
throw new AlreadyInDBError( );
`A note with the alias '${alias}' already exists.`, throw new AlreadyInDBError(
); `A note with the alias '${alias}' already exists.`,
);
} else {
throw e;
}
} }
} }
@ -304,7 +308,7 @@ export class NotesService {
* @param {Note} note - the note to use * @param {Note} note - the note to use
* @return {User} user to be used as updateUser in the NoteDto * @return {User} user to be used as updateUser in the NoteDto
*/ */
async calculateUpdateUser(note: Note): Promise<User> { async calculateUpdateUser(note: Note): Promise<User | null> {
const lastRevision = await this.getLatestRevision(note); const lastRevision = await this.getLatestRevision(note);
if (lastRevision && lastRevision.authorships) { if (lastRevision && lastRevision.authorships) {
// Sort the last Revisions Authorships by their updatedAt Date to get the latest one // Sort the last Revisions Authorships by their updatedAt Date to get the latest one
@ -333,7 +337,7 @@ export class NotesService {
*/ */
toNotePermissionsDto(note: Note): NotePermissionsDto { toNotePermissionsDto(note: Note): NotePermissionsDto {
return { return {
owner: this.usersService.toUserDto(note.owner), owner: note.owner ? this.usersService.toUserDto(note.owner) : null,
sharedToUsers: note.userPermissions.map((noteUserPermission) => ({ sharedToUsers: note.userPermissions.map((noteUserPermission) => ({
user: this.usersService.toUserDto(noteUserPermission.user), user: this.usersService.toUserDto(noteUserPermission.user),
canEdit: noteUserPermission.canEdit, canEdit: noteUserPermission.canEdit,
@ -352,10 +356,11 @@ export class NotesService {
* @return {NoteMetadataDto} the built NoteMetadataDto * @return {NoteMetadataDto} the built NoteMetadataDto
*/ */
async toNoteMetadataDto(note: Note): Promise<NoteMetadataDto> { async toNoteMetadataDto(note: Note): Promise<NoteMetadataDto> {
const updateUser = await this.calculateUpdateUser(note);
return { return {
// TODO: Convert DB UUID to base64 // TODO: Convert DB UUID to base64
id: note.id, id: note.id,
alias: note.alias, alias: note.alias ?? null,
title: note.title ?? '', title: note.title ?? '',
createTime: (await this.getFirstRevision(note)).createdAt, createTime: (await this.getFirstRevision(note)).createdAt,
description: note.description ?? '', description: note.description ?? '',
@ -365,9 +370,7 @@ export class NotesService {
permissions: this.toNotePermissionsDto(note), permissions: this.toNotePermissionsDto(note),
tags: this.toTagList(note), tags: this.toTagList(note),
updateTime: (await this.getLatestRevision(note)).createdAt, updateTime: (await this.getLatestRevision(note)).createdAt,
updateUser: this.usersService.toUserDto( updateUser: updateUser ? this.usersService.toUserDto(updateUser) : null,
await this.calculateUpdateUser(note),
),
viewCount: note.viewCount, viewCount: note.viewCount,
}; };
} }

View file

@ -6,6 +6,8 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { NotInDBError } from '../errors/errors';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { AuthorColor } from '../notes/author-color.entity'; import { AuthorColor } from '../notes/author-color.entity';
import { Note } from '../notes/note.entity'; import { Note } from '../notes/note.entity';
@ -25,6 +27,7 @@ import appConfigMock from '../config/mock/app.config.mock';
describe('RevisionsService', () => { describe('RevisionsService', () => {
let service: RevisionsService; let service: RevisionsService;
let revisionRepo: Repository<Revision>;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -32,7 +35,7 @@ describe('RevisionsService', () => {
RevisionsService, RevisionsService,
{ {
provide: getRepositoryToken(Revision), provide: getRepositoryToken(Revision),
useValue: {}, useClass: Repository,
}, },
], ],
imports: [ imports: [
@ -57,7 +60,7 @@ describe('RevisionsService', () => {
.overrideProvider(getRepositoryToken(Note)) .overrideProvider(getRepositoryToken(Note))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Revision)) .overrideProvider(getRepositoryToken(Revision))
.useValue({}) .useClass(Repository)
.overrideProvider(getRepositoryToken(Tag)) .overrideProvider(getRepositoryToken(Tag))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(NoteGroupPermission)) .overrideProvider(getRepositoryToken(NoteGroupPermission))
@ -69,9 +72,26 @@ describe('RevisionsService', () => {
.compile(); .compile();
service = module.get<RevisionsService>(RevisionsService); service = module.get<RevisionsService>(RevisionsService);
revisionRepo = module.get<Repository<Revision>>(
getRepositoryToken(Revision),
);
}); });
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
describe('getRevision', () => {
it('returns a revision', async () => {
const revision = Revision.create('', '');
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(revision);
expect(await service.getRevision({} as Note, 1)).toEqual(revision);
});
it('throws if the revision is not in the databse', async () => {
jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(undefined);
await expect(service.getRevision({} as Note, 1)).rejects.toThrow(
NotInDBError,
);
});
});
}); });

View file

@ -13,6 +13,7 @@ import { RevisionMetadataDto } from './revision-metadata.dto';
import { RevisionDto } from './revision.dto'; import { RevisionDto } from './revision.dto';
import { Revision } from './revision.entity'; import { Revision } from './revision.entity';
import { Note } from '../notes/note.entity'; import { Note } from '../notes/note.entity';
import { NotInDBError } from '../errors/errors';
@Injectable() @Injectable()
export class RevisionsService { export class RevisionsService {
@ -34,16 +35,22 @@ export class RevisionsService {
} }
async getRevision(note: Note, revisionId: number): Promise<Revision> { async getRevision(note: Note, revisionId: number): Promise<Revision> {
return await this.revisionRepository.findOne({ const revision = await this.revisionRepository.findOne({
where: { where: {
id: revisionId, id: revisionId,
note: note, note: note,
}, },
}); });
if (revision === undefined) {
throw new NotInDBError(
`Revision with ID ${revisionId} for note ${note.id} not found.`,
);
}
return revision;
} }
async getLatestRevision(noteId: string): Promise<Revision> { async getLatestRevision(noteId: string): Promise<Revision> {
return await this.revisionRepository.findOne({ const revision = await this.revisionRepository.findOne({
where: { where: {
note: noteId, note: noteId,
}, },
@ -52,10 +59,14 @@ export class RevisionsService {
id: 'DESC', id: 'DESC',
}, },
}); });
if (revision === undefined) {
throw new NotInDBError(`Revision for note ${noteId} not found.`);
}
return revision;
} }
async getFirstRevision(noteId: string): Promise<Revision> { async getFirstRevision(noteId: string): Promise<Revision> {
return await this.revisionRepository.findOne({ const revision = await this.revisionRepository.findOne({
where: { where: {
note: noteId, note: noteId,
}, },
@ -63,6 +74,10 @@ export class RevisionsService {
createdAt: 'ASC', createdAt: 'ASC',
}, },
}); });
if (revision === undefined) {
throw new NotInDBError(`Revision for note ${noteId} not found.`);
}
return revision;
} }
toRevisionMetadataDto(revision: Revision): RevisionMetadataDto { toRevisionMetadataDto(revision: Revision): RevisionMetadataDto {

View file

@ -56,7 +56,16 @@ createConnection({
user.ownedNotes = [note]; user.ownedNotes = [note];
await connection.manager.save([user, note, revision]); await connection.manager.save([user, note, revision]);
const foundUser = await connection.manager.findOne(User); const foundUser = await connection.manager.findOne(User);
if (!foundUser) {
throw new Error('Could not find freshly seeded user. Aborting.');
}
const foundNote = await connection.manager.findOne(Note); const foundNote = await connection.manager.findOne(Note);
if (!foundNote) {
throw new Error('Could not find freshly seeded note. Aborting.');
}
if (!foundNote.alias) {
throw new Error('Could not find alias of freshly seeded note. Aborting.');
}
const historyEntry = HistoryEntry.create(foundUser, foundNote); const historyEntry = HistoryEntry.create(foundUser, foundNote);
await connection.manager.save(historyEntry); await connection.manager.save(historyEntry);
console.log(`Created User '${foundUser.userName}'`); console.log(`Created User '${foundUser.userName}'`);

View file

@ -38,16 +38,19 @@ export class Identity {
@Column({ @Column({
nullable: true, nullable: true,
type: 'text',
}) })
providerUserId?: string; providerUserId: string | null;
@Column({ @Column({
nullable: true, nullable: true,
type: 'text',
}) })
oAuthAccessToken?: string; oAuthAccessToken: string | null;
@Column({ @Column({
nullable: true, nullable: true,
type: 'text',
}) })
passwordHash?: string; passwordHash: string | null;
} }

View file

@ -40,13 +40,15 @@ export class User {
@Column({ @Column({
nullable: true, nullable: true,
type: 'text',
}) })
photo?: string; photo: string | null;
@Column({ @Column({
nullable: true, nullable: true,
type: 'text',
}) })
email?: string; email: string | null;
@OneToMany((_) => Note, (note) => note.owner) @OneToMany((_) => Note, (note) => note.owner)
ownedNotes: Note[]; ownedNotes: Note[];

View file

@ -149,9 +149,5 @@ describe('UsersService', () => {
expect(userDto.photo).toEqual(''); expect(userDto.photo).toEqual('');
expect(userDto.email).toEqual(''); expect(userDto.email).toEqual('');
}); });
it('fails if no user is provided', () => {
expect(service.toUserDto(null)).toBeNull();
expect(service.toUserDto(undefined)).toBeNull();
});
}); });
}); });

View file

@ -108,13 +108,9 @@ export class UsersService {
/** /**
* Build UserInfoDto from a user. * Build UserInfoDto from a user.
* @param {User=} user - the user to use * @param {User=} user - the user to use
* @return {(UserInfoDto|null)} the built UserInfoDto * @return {(UserInfoDto)} the built UserInfoDto
*/ */
toUserDto(user: User | null | undefined): UserInfoDto | null { toUserDto(user: User): UserInfoDto {
if (!user) {
this.logger.warn(`Recieved ${String(user)} argument!`, 'toUserDto');
return null;
}
return { return {
userName: user.userName, userName: user.userName,
displayName: user.displayName, displayName: user.displayName,

View file

@ -185,7 +185,7 @@ describe('History', () => {
const entry = await historyService.createOrUpdateHistoryEntry(note2, user); const entry = await historyService.createOrUpdateHistoryEntry(note2, user);
expect(entry.pinStatus).toBeFalsy(); expect(entry.pinStatus).toBeFalsy();
await request(app.getHttpServer()) await request(app.getHttpServer())
.put(`/me/history/${entry.note.alias}`) .put(`/me/history/${entry.note.alias || 'undefined'}`)
.send({ pinStatus: true }) .send({ pinStatus: true })
.expect(200); .expect(200);
const userEntries = await historyService.getEntriesByUser(user); const userEntries = await historyService.getEntriesByUser(user);
@ -199,7 +199,7 @@ describe('History', () => {
const entry2 = await historyService.createOrUpdateHistoryEntry(note, user); const entry2 = await historyService.createOrUpdateHistoryEntry(note, user);
const entryDto = historyService.toHistoryEntryDto(entry2); const entryDto = historyService.toHistoryEntryDto(entry2);
await request(app.getHttpServer()) await request(app.getHttpServer())
.delete(`/me/history/${entry.note.alias}`) .delete(`/me/history/${entry.note.alias || 'undefined'}`)
.expect(200); .expect(200);
const userEntries = await historyService.getEntriesByUser(user); const userEntries = await historyService.getEntriesByUser(user);
expect(userEntries.length).toEqual(1); expect(userEntries.length).toEqual(1);

View file

@ -134,7 +134,7 @@ describe('Media', () => {
'hardcoded', 'hardcoded',
'test_upload_media', 'test_upload_media',
); );
const filename = url.split('/').pop(); const filename = url.split('/').pop() || '';
await request(app.getHttpServer()) await request(app.getHttpServer())
.delete('/media/' + filename) .delete('/media/' + filename)
.expect(204); .expect(204);

View file

@ -14,6 +14,7 @@
"./types", "./types",
"./node_modules/@types" "./node_modules/@types"
], ],
"strict": false "strict": true,
"strictPropertyInitialization": false
} }
} }

6
tsconfig.test.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"strict": false
}
}

View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
SPDX-License-Identifier: CC0-1.0