mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-07 18:04:56 -04:00
Merge pull request #992 from hedgedoc/privateApi/me
This commit is contained in:
commit
2c75de747f
15 changed files with 542 additions and 71 deletions
83
src/api/private/me/me.controller.spec.ts
Normal file
83
src/api/private/me/me.controller.spec.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { MeController } from './me.controller';
|
||||||
|
import { UsersModule } from '../../../users/users.module';
|
||||||
|
import { LoggerModule } from '../../../logger/logger.module';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { User } from '../../../users/user.entity';
|
||||||
|
import { Identity } from '../../../users/identity.entity';
|
||||||
|
import { MediaModule } from '../../../media/media.module';
|
||||||
|
import { AuthorColor } from '../../../notes/author-color.entity';
|
||||||
|
import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity';
|
||||||
|
import { NoteUserPermission } from '../../../permissions/note-user-permission.entity';
|
||||||
|
import { Authorship } from '../../../revisions/authorship.entity';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import appConfigMock from '../../../config/mock/app.config.mock';
|
||||||
|
import authConfigMock from '../../../config/mock/auth.config.mock';
|
||||||
|
import mediaConfigMock from '../../../config/mock/media.config.mock';
|
||||||
|
import customizationConfigMock from '../../../config/mock/customization.config.mock';
|
||||||
|
import externalServicesConfigMock from '../../../config/mock/external-services.config.mock';
|
||||||
|
import { MediaUpload } from '../../../media/media-upload.entity';
|
||||||
|
import { Note } from '../../../notes/note.entity';
|
||||||
|
import { Tag } from '../../../notes/tag.entity';
|
||||||
|
import { Revision } from '../../../revisions/revision.entity';
|
||||||
|
import { Group } from '../../../groups/group.entity';
|
||||||
|
|
||||||
|
describe('MeController', () => {
|
||||||
|
let controller: MeController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [MeController],
|
||||||
|
imports: [
|
||||||
|
UsersModule,
|
||||||
|
LoggerModule,
|
||||||
|
MediaModule,
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [
|
||||||
|
appConfigMock,
|
||||||
|
authConfigMock,
|
||||||
|
mediaConfigMock,
|
||||||
|
customizationConfigMock,
|
||||||
|
externalServicesConfigMock,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideProvider(getRepositoryToken(User))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(Identity))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(Note))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(Tag))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(Revision))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(Group))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(AuthorColor))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(NoteGroupPermission))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(NoteUserPermission))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(Authorship))
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(getRepositoryToken(MediaUpload))
|
||||||
|
.useValue({})
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<MeController>(MeController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
60
src/api/private/me/me.controller.ts
Normal file
60
src/api/private/me/me.controller.ts
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Body, Controller, Delete, Get, HttpCode, Post } from '@nestjs/common';
|
||||||
|
import { UserInfoDto } from '../../../users/user-info.dto';
|
||||||
|
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
|
||||||
|
import { UsersService } from '../../../users/users.service';
|
||||||
|
import { MediaService } from '../../../media/media.service';
|
||||||
|
import { MediaUploadDto } from '../../../media/media-upload.dto';
|
||||||
|
|
||||||
|
@Controller('me')
|
||||||
|
export class MeController {
|
||||||
|
constructor(
|
||||||
|
private readonly logger: ConsoleLoggerService,
|
||||||
|
private userService: UsersService,
|
||||||
|
private mediaService: MediaService,
|
||||||
|
) {
|
||||||
|
this.logger.setContext(MeController.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getMe(): Promise<UserInfoDto> {
|
||||||
|
// ToDo: use actual user here
|
||||||
|
const user = await this.userService.getUserByUsername('hardcoded');
|
||||||
|
return this.userService.toUserDto(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('media')
|
||||||
|
async getMyMedia(): Promise<MediaUploadDto[]> {
|
||||||
|
// ToDo: use actual user here
|
||||||
|
const user = await this.userService.getUserByUsername('hardcoded');
|
||||||
|
const media = await this.mediaService.listUploadsByUser(user);
|
||||||
|
return media.map((media) => this.mediaService.toMediaUploadDto(media));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
@HttpCode(204)
|
||||||
|
async deleteUser(): Promise<void> {
|
||||||
|
// ToDo: use actual user here
|
||||||
|
const user = await this.userService.getUserByUsername('hardcoded');
|
||||||
|
const mediaUploads = await this.mediaService.listUploadsByUser(user);
|
||||||
|
for (const mediaUpload of mediaUploads) {
|
||||||
|
await this.mediaService.deleteFile(mediaUpload);
|
||||||
|
}
|
||||||
|
this.logger.debug(`Deleted all media uploads of ${user.userName}`);
|
||||||
|
await this.userService.deleteUser(user);
|
||||||
|
this.logger.debug(`Deleted ${user.userName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('profile')
|
||||||
|
@HttpCode(200)
|
||||||
|
async updateDisplayName(@Body('name') newDisplayName: string): Promise<void> {
|
||||||
|
// ToDo: use actual user here
|
||||||
|
const user = await this.userService.getUserByUsername('hardcoded');
|
||||||
|
await this.userService.changeDisplayName(user, newDisplayName);
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import { TokensController } from './tokens/tokens.controller';
|
||||||
import { LoggerModule } from '../../logger/logger.module';
|
import { LoggerModule } from '../../logger/logger.module';
|
||||||
import { UsersModule } from '../../users/users.module';
|
import { UsersModule } from '../../users/users.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
|
import { MeController } from './me/me.controller';
|
||||||
import { ConfigController } from './config/config.controller';
|
import { ConfigController } from './config/config.controller';
|
||||||
import { FrontendConfigModule } from '../../frontend-config/frontend-config.module';
|
import { FrontendConfigModule } from '../../frontend-config/frontend-config.module';
|
||||||
import { HistoryController } from './me/history/history.controller';
|
import { HistoryController } from './me/history/history.controller';
|
||||||
|
@ -27,8 +28,8 @@ import { RevisionsModule } from '../../revisions/revisions.module';
|
||||||
AuthModule,
|
AuthModule,
|
||||||
FrontendConfigModule,
|
FrontendConfigModule,
|
||||||
HistoryModule,
|
HistoryModule,
|
||||||
NotesModule,
|
|
||||||
PermissionsModule,
|
PermissionsModule,
|
||||||
|
NotesModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
RevisionsModule,
|
RevisionsModule,
|
||||||
],
|
],
|
||||||
|
@ -37,6 +38,7 @@ import { RevisionsModule } from '../../revisions/revisions.module';
|
||||||
ConfigController,
|
ConfigController,
|
||||||
MediaController,
|
MediaController,
|
||||||
HistoryController,
|
HistoryController,
|
||||||
|
MeController,
|
||||||
NotesController,
|
NotesController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
|
@ -132,7 +132,23 @@ export class MediaController {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const username = req.user.userName;
|
const username = req.user.userName;
|
||||||
try {
|
try {
|
||||||
await this.mediaService.deleteFile(filename, username);
|
this.logger.debug(
|
||||||
|
`Deleting '${filename}' for user '${username}'`,
|
||||||
|
'deleteFile',
|
||||||
|
);
|
||||||
|
const mediaUpload = await this.mediaService.findUploadByFilename(
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
if (mediaUpload.user.userName !== username) {
|
||||||
|
this.logger.warn(
|
||||||
|
`${username} tried to delete '${filename}', but is not the owner`,
|
||||||
|
'deleteFile',
|
||||||
|
);
|
||||||
|
throw new PermissionError(
|
||||||
|
`File '${filename}' is not owned by '${username}'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.mediaService.deleteFile(mediaUpload);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionError) {
|
if (e instanceof PermissionError) {
|
||||||
throw new UnauthorizedException(e.message);
|
throw new UnauthorizedException(e.message);
|
||||||
|
|
|
@ -21,7 +21,9 @@ export class AuthToken {
|
||||||
@Column({ unique: true })
|
@Column({ unique: true })
|
||||||
keyId: string;
|
keyId: string;
|
||||||
|
|
||||||
@ManyToOne((_) => User, (user) => user.authTokens)
|
@ManyToOne((_) => User, (user) => user.authTokens, {
|
||||||
|
onDelete: 'CASCADE', // This deletes the AuthToken, when the associated User is deleted
|
||||||
|
})
|
||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
|
|
|
@ -36,7 +36,6 @@ export class Group {
|
||||||
|
|
||||||
@ManyToMany((_) => User, (user) => user.groups, {
|
@ManyToMany((_) => User, (user) => user.groups, {
|
||||||
eager: true,
|
eager: true,
|
||||||
cascade: true,
|
|
||||||
})
|
})
|
||||||
@JoinTable()
|
@JoinTable()
|
||||||
members: User[];
|
members: User[];
|
||||||
|
|
|
@ -23,10 +23,14 @@ export class MediaUpload {
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ManyToOne((_) => Note, { nullable: false })
|
@ManyToOne((_) => Note, (note) => note.mediaUploads, {
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
note: Note;
|
note: Note;
|
||||||
|
|
||||||
@ManyToOne((_) => User, { nullable: false })
|
@ManyToOne((_) => User, (user) => user.mediaUploads, {
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
|
|
|
@ -24,7 +24,7 @@ import { BackendData, MediaUpload } from './media-upload.entity';
|
||||||
import { MediaService } from './media.service';
|
import { MediaService } from './media.service';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { ClientError, NotInDBError, PermissionError } from '../errors/errors';
|
import { ClientError, NotInDBError } from '../errors/errors';
|
||||||
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
|
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
|
||||||
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
|
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
|
||||||
import { Group } from '../groups/group.entity';
|
import { Group } from '../groups/group.entity';
|
||||||
|
@ -145,7 +145,6 @@ describe('MediaService', () => {
|
||||||
|
|
||||||
describe('deleteFile', () => {
|
describe('deleteFile', () => {
|
||||||
it('works', async () => {
|
it('works', async () => {
|
||||||
const testFileName = 'testFilename';
|
|
||||||
const mockMediaUploadEntry = {
|
const mockMediaUploadEntry = {
|
||||||
id: 'testMediaUpload',
|
id: 'testMediaUpload',
|
||||||
backendData: 'testBackendData',
|
backendData: 'testBackendData',
|
||||||
|
@ -153,12 +152,9 @@ describe('MediaService', () => {
|
||||||
userName: 'hardcoded',
|
userName: 'hardcoded',
|
||||||
} as User,
|
} as User,
|
||||||
} as MediaUpload;
|
} as MediaUpload;
|
||||||
jest
|
|
||||||
.spyOn(mediaRepo, 'findOne')
|
|
||||||
.mockResolvedValueOnce(mockMediaUploadEntry);
|
|
||||||
jest.spyOn(service.mediaBackend, 'deleteFile').mockImplementationOnce(
|
jest.spyOn(service.mediaBackend, 'deleteFile').mockImplementationOnce(
|
||||||
async (fileName: string, backendData: BackendData): Promise<void> => {
|
async (fileName: string, backendData: BackendData): Promise<void> => {
|
||||||
expect(fileName).toEqual(testFileName);
|
expect(fileName).toEqual(mockMediaUploadEntry.id);
|
||||||
expect(backendData).toEqual(mockMediaUploadEntry.backendData);
|
expect(backendData).toEqual(mockMediaUploadEntry.backendData);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -168,24 +164,7 @@ describe('MediaService', () => {
|
||||||
expect(entry).toEqual(mockMediaUploadEntry);
|
expect(entry).toEqual(mockMediaUploadEntry);
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
await service.deleteFile(testFileName, 'hardcoded');
|
await service.deleteFile(mockMediaUploadEntry);
|
||||||
});
|
|
||||||
|
|
||||||
it('fails: the mediaUpload is not owned by user', async () => {
|
|
||||||
const testFileName = 'testFilename';
|
|
||||||
const mockMediaUploadEntry = {
|
|
||||||
id: 'testMediaUpload',
|
|
||||||
backendData: 'testBackendData',
|
|
||||||
user: {
|
|
||||||
userName: 'not-hardcoded',
|
|
||||||
} as User,
|
|
||||||
} as MediaUpload;
|
|
||||||
jest
|
|
||||||
.spyOn(mediaRepo, 'findOne')
|
|
||||||
.mockResolvedValueOnce(mockMediaUploadEntry);
|
|
||||||
await expect(
|
|
||||||
service.deleteFile(testFileName, 'hardcoded'),
|
|
||||||
).rejects.toThrow(PermissionError);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('findUploadByFilename', () => {
|
describe('findUploadByFilename', () => {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import * as FileType from 'file-type';
|
import * as FileType from 'file-type';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import mediaConfiguration, { MediaConfig } from '../config/media.config';
|
import mediaConfiguration, { MediaConfig } from '../config/media.config';
|
||||||
import { ClientError, NotInDBError, PermissionError } from '../errors/errors';
|
import { ClientError, NotInDBError } from '../errors/errors';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
import { NotesService } from '../notes/notes.service';
|
import { NotesService } from '../notes/notes.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
@ -113,30 +113,12 @@ export class MediaService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @async
|
* @async
|
||||||
* Try to delete the file specified by the filename with the user specified by the username.
|
* Try to delete the specified file.
|
||||||
* @param {string} filename - the name of the file to delete.
|
* @param {MediaUpload} mediaUpload - the name of the file to delete.
|
||||||
* @param {string} username - the username of the user who uploaded this file
|
|
||||||
* @return {string} the url of the saved file
|
|
||||||
* @throws {PermissionError} the user is not permitted to delete this file.
|
|
||||||
* @throws {NotInDBError} - the file entry specified is not in the database
|
|
||||||
* @throws {MediaBackendError} - there was an error deleting the file
|
* @throws {MediaBackendError} - there was an error deleting the file
|
||||||
*/
|
*/
|
||||||
async deleteFile(filename: string, username: string): Promise<void> {
|
async deleteFile(mediaUpload: MediaUpload): Promise<void> {
|
||||||
this.logger.debug(
|
await this.mediaBackend.deleteFile(mediaUpload.id, mediaUpload.backendData);
|
||||||
`Deleting '${filename}' for user '${username}'`,
|
|
||||||
'deleteFile',
|
|
||||||
);
|
|
||||||
const mediaUpload = await this.findUploadByFilename(filename);
|
|
||||||
if (mediaUpload.user.userName !== username) {
|
|
||||||
this.logger.warn(
|
|
||||||
`${username} tried to delete '${filename}', but is not the owner`,
|
|
||||||
'deleteFile',
|
|
||||||
);
|
|
||||||
throw new PermissionError(
|
|
||||||
`File '${filename}' is not owned by '${username}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.mediaBackend.deleteFile(filename, mediaUpload.backendData);
|
|
||||||
await this.mediaUploadRepository.remove(mediaUpload);
|
await this.mediaUploadRepository.remove(mediaUpload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { User } from '../users/user.entity';
|
||||||
import { AuthorColor } from './author-color.entity';
|
import { AuthorColor } from './author-color.entity';
|
||||||
import { Tag } from './tag.entity';
|
import { Tag } from './tag.entity';
|
||||||
import { HistoryEntry } from '../history/history-entry.entity';
|
import { HistoryEntry } from '../history/history-entry.entity';
|
||||||
|
import { MediaUpload } from '../media/media-upload.entity';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Note {
|
export class Note {
|
||||||
|
@ -53,7 +54,9 @@ export class Note {
|
||||||
default: 0,
|
default: 0,
|
||||||
})
|
})
|
||||||
viewCount: number;
|
viewCount: number;
|
||||||
@ManyToOne((_) => User, (user) => user.ownedNotes, { onDelete: 'CASCADE' })
|
@ManyToOne((_) => User, (user) => user.ownedNotes, {
|
||||||
|
onDelete: 'CASCADE', // This deletes the Note, when the associated User is deleted
|
||||||
|
})
|
||||||
owner: User;
|
owner: User;
|
||||||
@OneToMany((_) => Revision, (revision) => revision.note, { cascade: true })
|
@OneToMany((_) => Revision, (revision) => revision.note, { cascade: true })
|
||||||
revisions: Promise<Revision[]>;
|
revisions: Promise<Revision[]>;
|
||||||
|
@ -61,6 +64,8 @@ export class Note {
|
||||||
authorColors: AuthorColor[];
|
authorColors: AuthorColor[];
|
||||||
@OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user)
|
@OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user)
|
||||||
historyEntries: HistoryEntry[];
|
historyEntries: HistoryEntry[];
|
||||||
|
@OneToMany((_) => MediaUpload, (mediaUpload) => mediaUpload.note)
|
||||||
|
mediaUploads: MediaUpload[];
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|
|
@ -19,7 +19,9 @@ export class Identity {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
id: number;
|
||||||
|
|
||||||
@ManyToOne((_) => User, (user) => user.identities)
|
@ManyToOne((_) => User, (user) => user.identities, {
|
||||||
|
onDelete: 'CASCADE', // This deletes the Identity, when the associated User is deleted
|
||||||
|
})
|
||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
|
|
|
@ -17,13 +17,16 @@ import { AuthToken } from '../auth/auth-token.entity';
|
||||||
import { Identity } from './identity.entity';
|
import { Identity } from './identity.entity';
|
||||||
import { Group } from '../groups/group.entity';
|
import { Group } from '../groups/group.entity';
|
||||||
import { HistoryEntry } from '../history/history-entry.entity';
|
import { HistoryEntry } from '../history/history-entry.entity';
|
||||||
|
import { MediaUpload } from '../media/media-upload.entity';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class User {
|
export class User {
|
||||||
@PrimaryGeneratedColumn('uuid')
|
@PrimaryGeneratedColumn('uuid')
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@Column()
|
@Column({
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
userName: string;
|
userName: string;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
|
@ -60,6 +63,9 @@ export class User {
|
||||||
@OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user)
|
@OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user)
|
||||||
historyEntries: HistoryEntry[];
|
historyEntries: HistoryEntry[];
|
||||||
|
|
||||||
|
@OneToMany((_) => MediaUpload, (mediaUpload) => mediaUpload.user)
|
||||||
|
mediaUploads: MediaUpload[];
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,14 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { LoggerModule } from '../logger/logger.module';
|
import { LoggerModule } from '../logger/logger.module';
|
||||||
import { User } from './user.entity';
|
import { User } from './user.entity';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import appConfigMock from '../config/mock/app.config.mock';
|
import appConfigMock from '../config/mock/app.config.mock';
|
||||||
|
|
||||||
describe('UsersService', () => {
|
describe('UsersService', () => {
|
||||||
let service: UsersService;
|
let service: UsersService;
|
||||||
|
let userRepo: Repository<User>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
@ -21,7 +24,7 @@ describe('UsersService', () => {
|
||||||
UsersService,
|
UsersService,
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(User),
|
provide: getRepositoryToken(User),
|
||||||
useValue: {},
|
useClass: Repository,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -31,15 +34,124 @@ describe('UsersService', () => {
|
||||||
}),
|
}),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
],
|
],
|
||||||
})
|
}).compile();
|
||||||
.overrideProvider(getRepositoryToken(User))
|
|
||||||
.useValue({})
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
service = module.get<UsersService>(UsersService);
|
service = module.get<UsersService>(UsersService);
|
||||||
|
userRepo = module.get<Repository<User>>(getRepositoryToken(User));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
expect(service).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createUser', () => {
|
||||||
|
const username = 'hardcoded';
|
||||||
|
const displayname = 'Testy';
|
||||||
|
beforeEach(() => {
|
||||||
|
jest
|
||||||
|
.spyOn(userRepo, 'save')
|
||||||
|
.mockImplementationOnce(async (user: User): Promise<User> => user);
|
||||||
|
});
|
||||||
|
it('works', async () => {
|
||||||
|
const user = await service.createUser(username, displayname);
|
||||||
|
expect(user.userName).toEqual(username);
|
||||||
|
expect(user.displayName).toEqual(displayname);
|
||||||
|
});
|
||||||
|
it('fails if username is already taken', async () => {
|
||||||
|
jest.spyOn(userRepo, 'save').mockImplementationOnce(() => {
|
||||||
|
throw new Error();
|
||||||
|
});
|
||||||
|
// create first user with username
|
||||||
|
await service.createUser(username, displayname);
|
||||||
|
// attempt to create second user with username
|
||||||
|
await expect(service.createUser(username, displayname)).rejects.toThrow(
|
||||||
|
AlreadyInDBError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteUser', () => {
|
||||||
|
it('works', async () => {
|
||||||
|
const username = 'hardcoded';
|
||||||
|
const displayname = 'Testy';
|
||||||
|
const newUser = User.create(username, displayname) as User;
|
||||||
|
jest.spyOn(userRepo, 'remove').mockImplementationOnce(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
|
async (user: User): Promise<User> => {
|
||||||
|
expect(user).toEqual(newUser);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await service.deleteUser(newUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('changedDisplayName', () => {
|
||||||
|
it('works', async () => {
|
||||||
|
const username = 'hardcoded';
|
||||||
|
const displayname = 'Testy';
|
||||||
|
const user = User.create(username, displayname) as User;
|
||||||
|
const newDisplayName = 'Testy2';
|
||||||
|
jest.spyOn(userRepo, 'save').mockImplementationOnce(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
|
async (user: User): Promise<User> => {
|
||||||
|
expect(user.displayName).toEqual(newDisplayName);
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await service.changeDisplayName(user, newDisplayName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUserByUsername', () => {
|
||||||
|
const username = 'hardcoded';
|
||||||
|
const displayname = 'Testy';
|
||||||
|
const user = User.create(username, displayname) as User;
|
||||||
|
it('works', async () => {
|
||||||
|
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
|
||||||
|
const getUser = await service.getUserByUsername(username);
|
||||||
|
expect(getUser.userName).toEqual(username);
|
||||||
|
expect(getUser.displayName).toEqual(displayname);
|
||||||
|
});
|
||||||
|
it('fails when user does not exits', async () => {
|
||||||
|
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(undefined);
|
||||||
|
await expect(service.getUserByUsername(username)).rejects.toThrow(
|
||||||
|
NotInDBError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPhotoUrl', () => {
|
||||||
|
const username = 'hardcoded';
|
||||||
|
const displayname = 'Testy';
|
||||||
|
const user = User.create(username, displayname) as User;
|
||||||
|
it('works if a user has a photoUrl', () => {
|
||||||
|
const photo = 'testPhotoUrl';
|
||||||
|
user.photo = photo;
|
||||||
|
const photoUrl = service.getPhotoUrl(user);
|
||||||
|
expect(photoUrl).toEqual(photo);
|
||||||
|
});
|
||||||
|
it('works if a user no photoUrl', () => {
|
||||||
|
user.photo = undefined;
|
||||||
|
const photoUrl = service.getPhotoUrl(user);
|
||||||
|
expect(photoUrl).toEqual('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toUserDto', () => {
|
||||||
|
const username = 'hardcoded';
|
||||||
|
const displayname = 'Testy';
|
||||||
|
const user = User.create(username, displayname) as User;
|
||||||
|
it('works if a user is provided', () => {
|
||||||
|
const userDto = service.toUserDto(user);
|
||||||
|
expect(userDto.userName).toEqual(username);
|
||||||
|
expect(userDto.displayName).toEqual(displayname);
|
||||||
|
expect(userDto.photo).toEqual('');
|
||||||
|
expect(userDto.email).toEqual('');
|
||||||
|
});
|
||||||
|
it('fails if no user is provided', () => {
|
||||||
|
expect(service.toUserDto(null)).toBeNull();
|
||||||
|
expect(service.toUserDto(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { NotInDBError } from '../errors/errors';
|
import { AlreadyInDBError, NotInDBError } from '../errors/errors';
|
||||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||||
import { UserInfoDto } from './user-info.dto';
|
import { UserInfoDto } from './user-info.dto';
|
||||||
import { User } from './user.entity';
|
import { User } from './user.entity';
|
||||||
|
@ -21,23 +21,69 @@ export class UsersService {
|
||||||
this.logger.setContext(UsersService.name);
|
this.logger.setContext(UsersService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
createUser(userName: string, displayName: string): Promise<User> {
|
/**
|
||||||
|
* @async
|
||||||
|
* Create a new user with a given userName and displayName
|
||||||
|
* @param userName - the userName the new user shall have
|
||||||
|
* @param displayName - the display the new user shall have
|
||||||
|
* @return {User} the user
|
||||||
|
* @throws {AlreadyInDBError} the userName is already taken.
|
||||||
|
*/
|
||||||
|
async createUser(userName: string, displayName: string): Promise<User> {
|
||||||
const user = User.create(userName, displayName);
|
const user = User.create(userName, displayName);
|
||||||
return this.userRepository.save(user);
|
try {
|
||||||
|
return await this.userRepository.save(user);
|
||||||
|
} catch {
|
||||||
|
this.logger.debug(
|
||||||
|
`A user with the username '${userName}' already exists.`,
|
||||||
|
'createUser',
|
||||||
|
);
|
||||||
|
throw new AlreadyInDBError(
|
||||||
|
`A user with the username '${userName}' already exists.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUser(userName: string): Promise<void> {
|
/**
|
||||||
// TODO: Handle owned notes and edits
|
* @async
|
||||||
const user = await this.userRepository.findOne({
|
* Delete the user with the specified userName
|
||||||
where: { userName: userName },
|
* @param {User} user - the username of the user to be delete
|
||||||
});
|
* @throws {NotInDBError} the userName has no user associated with it.
|
||||||
await this.userRepository.delete(user);
|
*/
|
||||||
|
async deleteUser(user: User): Promise<void> {
|
||||||
|
await this.userRepository.remove(user);
|
||||||
|
this.logger.debug(
|
||||||
|
`Successfully deleted user with username ${user.userName}`,
|
||||||
|
'deleteUser',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @async
|
||||||
|
* Change the displayName of the specified user
|
||||||
|
* @param {User} user - the user to be changed
|
||||||
|
* @param displayName - the new displayName
|
||||||
|
*/
|
||||||
|
async changeDisplayName(user: User, displayName: string): Promise<void> {
|
||||||
|
user.displayName = displayName;
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @async
|
||||||
|
* Get the user specified by the username
|
||||||
|
* @param {string} userName the username by which the user is specified
|
||||||
|
* @param {boolean} [withTokens=false] if the returned user object should contain authTokens
|
||||||
|
* @return {User} the specified user
|
||||||
|
*/
|
||||||
async getUserByUsername(userName: string, withTokens = false): Promise<User> {
|
async getUserByUsername(userName: string, withTokens = false): Promise<User> {
|
||||||
|
const relations: string[] = [];
|
||||||
|
if (withTokens) {
|
||||||
|
relations.push('authTokens');
|
||||||
|
}
|
||||||
const user = await this.userRepository.findOne({
|
const user = await this.userRepository.findOne({
|
||||||
where: { userName: userName },
|
where: { userName: userName },
|
||||||
relations: withTokens ? ['authTokens'] : null,
|
relations: relations,
|
||||||
});
|
});
|
||||||
if (user === undefined) {
|
if (user === undefined) {
|
||||||
throw new NotInDBError(`User with username '${userName}' not found`);
|
throw new NotInDBError(`User with username '${userName}' not found`);
|
||||||
|
@ -45,6 +91,11 @@ export class UsersService {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the photoUrl of the user or in case no photo url is present generate a deterministic user photo
|
||||||
|
* @param {User} user - the specified User
|
||||||
|
* @return the url of the photo
|
||||||
|
*/
|
||||||
getPhotoUrl(user: User): string {
|
getPhotoUrl(user: User): string {
|
||||||
if (user.photo) {
|
if (user.photo) {
|
||||||
return user.photo;
|
return user.photo;
|
||||||
|
@ -54,6 +105,11 @@ export class UsersService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build UserInfoDto from a user.
|
||||||
|
* @param {User=} user - the user to use
|
||||||
|
* @return {(UserInfoDto|null)} the built UserInfoDto
|
||||||
|
*/
|
||||||
toUserDto(user: User | null | undefined): UserInfoDto | null {
|
toUserDto(user: User | null | undefined): UserInfoDto | null {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.logger.warn(`Recieved ${String(user)} argument!`, 'toUserDto');
|
this.logger.warn(`Recieved ${String(user)} argument!`, 'toUserDto');
|
||||||
|
|
163
test/private-api/me.e2e-spec.ts
Normal file
163
test/private-api/me.e2e-spec.ts
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable
|
||||||
|
@typescript-eslint/no-unsafe-assignment,
|
||||||
|
@typescript-eslint/no-unsafe-member-access
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import * as request from 'supertest';
|
||||||
|
import appConfigMock from '../../src/config/mock/app.config.mock';
|
||||||
|
import authConfigMock from '../../src/config/mock/auth.config.mock';
|
||||||
|
import mediaConfigMock from '../../src/config/mock/media.config.mock';
|
||||||
|
import customizationConfigMock from '../../src/config/mock/customization.config.mock';
|
||||||
|
import externalServicesConfigMock from '../../src/config/mock/external-services.config.mock';
|
||||||
|
import { GroupsModule } from '../../src/groups/groups.module';
|
||||||
|
import { LoggerModule } from '../../src/logger/logger.module';
|
||||||
|
import { NotesModule } from '../../src/notes/notes.module';
|
||||||
|
import { PermissionsModule } from '../../src/permissions/permissions.module';
|
||||||
|
import { AuthModule } from '../../src/auth/auth.module';
|
||||||
|
import { UsersService } from '../../src/users/users.service';
|
||||||
|
import { User } from '../../src/users/user.entity';
|
||||||
|
import { UsersModule } from '../../src/users/users.module';
|
||||||
|
import { PrivateApiModule } from '../../src/api/private/private-api.module';
|
||||||
|
import { UserInfoDto } from '../../src/users/user-info.dto';
|
||||||
|
import { MediaModule } from '../../src/media/media.module';
|
||||||
|
import { HistoryModule } from '../../src/history/history.module';
|
||||||
|
import { NotInDBError } from '../../src/errors/errors';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { Note } from '../../src/notes/note.entity';
|
||||||
|
import { NotesService } from '../../src/notes/notes.service';
|
||||||
|
import { MediaService } from '../../src/media/media.service';
|
||||||
|
|
||||||
|
describe('Me', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let userService: UsersService;
|
||||||
|
let mediaService: MediaService;
|
||||||
|
let uploadPath: string;
|
||||||
|
let user: User;
|
||||||
|
let content: string;
|
||||||
|
let note1: Note;
|
||||||
|
let note2: Note;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [
|
||||||
|
appConfigMock,
|
||||||
|
authConfigMock,
|
||||||
|
mediaConfigMock,
|
||||||
|
customizationConfigMock,
|
||||||
|
externalServicesConfigMock,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
PrivateApiModule,
|
||||||
|
NotesModule,
|
||||||
|
PermissionsModule,
|
||||||
|
GroupsModule,
|
||||||
|
TypeOrmModule.forRoot({
|
||||||
|
type: 'sqlite',
|
||||||
|
database: './hedgedoc-e2e-private-me.sqlite',
|
||||||
|
autoLoadEntities: true,
|
||||||
|
synchronize: true,
|
||||||
|
dropSchema: true,
|
||||||
|
}),
|
||||||
|
LoggerModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
MediaModule,
|
||||||
|
HistoryModule,
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
const config = moduleRef.get<ConfigService>(ConfigService);
|
||||||
|
uploadPath = config.get('mediaConfig').backend.filesystem.uploadPath;
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
//historyService = moduleRef.get();
|
||||||
|
userService = moduleRef.get(UsersService);
|
||||||
|
mediaService = moduleRef.get(MediaService);
|
||||||
|
user = await userService.createUser('hardcoded', 'Testy');
|
||||||
|
const notesService = moduleRef.get(NotesService);
|
||||||
|
content = 'This is a test note.';
|
||||||
|
note1 = await notesService.createNote(content, null, user);
|
||||||
|
note2 = await notesService.createNote(content, 'note2', user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /me', async () => {
|
||||||
|
const userInfo = userService.toUserDto(user);
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/me')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
const gotUser = response.body as UserInfoDto;
|
||||||
|
expect(gotUser).toEqual(userInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /me/media', async () => {
|
||||||
|
const httpServer = app.getHttpServer();
|
||||||
|
const responseBefore = await request(httpServer)
|
||||||
|
.get('/me/media/')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
expect(responseBefore.body).toHaveLength(0);
|
||||||
|
|
||||||
|
const testImage = await fs.readFile('test/public-api/fixtures/test.png');
|
||||||
|
const url0 = await mediaService.saveFile(testImage, 'hardcoded', note1.id);
|
||||||
|
const url1 = await mediaService.saveFile(testImage, 'hardcoded', note1.id);
|
||||||
|
const url2 = await mediaService.saveFile(testImage, 'hardcoded', note2.id);
|
||||||
|
const url3 = await mediaService.saveFile(testImage, 'hardcoded', note2.id);
|
||||||
|
|
||||||
|
const response = await request(httpServer)
|
||||||
|
.get('/me/media/')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body).toHaveLength(4);
|
||||||
|
expect(response.body[0].url).toEqual(url0);
|
||||||
|
expect(response.body[1].url).toEqual(url1);
|
||||||
|
expect(response.body[2].url).toEqual(url2);
|
||||||
|
expect(response.body[3].url).toEqual(url3);
|
||||||
|
const mediaUploads = await mediaService.listUploadsByUser(user);
|
||||||
|
for (const upload of mediaUploads) {
|
||||||
|
await mediaService.deleteFile(upload);
|
||||||
|
}
|
||||||
|
await fs.rmdir(uploadPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /me/profile', async () => {
|
||||||
|
const newDisplayName = 'Another name';
|
||||||
|
expect(user.displayName).not.toEqual(newDisplayName);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/me/profile')
|
||||||
|
.send({
|
||||||
|
name: newDisplayName,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
const dbUser = await userService.getUserByUsername('hardcoded');
|
||||||
|
expect(dbUser.displayName).toEqual(newDisplayName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /me', async () => {
|
||||||
|
const testImage = await fs.readFile('test/public-api/fixtures/test.png');
|
||||||
|
const url0 = await mediaService.saveFile(testImage, 'hardcoded', note1.id);
|
||||||
|
const dbUser = await userService.getUserByUsername('hardcoded');
|
||||||
|
expect(dbUser).toBeInstanceOf(User);
|
||||||
|
const mediaUploads = await mediaService.listUploadsByUser(dbUser);
|
||||||
|
expect(mediaUploads).toHaveLength(1);
|
||||||
|
expect(mediaUploads[0].fileUrl).toEqual(url0);
|
||||||
|
await request(app.getHttpServer()).delete('/me').expect(204);
|
||||||
|
await expect(userService.getUserByUsername('hardcoded')).rejects.toThrow(
|
||||||
|
NotInDBError,
|
||||||
|
);
|
||||||
|
const mediaUploadsAfter = await mediaService.listUploadsByNote(note1);
|
||||||
|
expect(mediaUploadsAfter).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue