Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

N21-1887 Rollback user from a user login migration #4978

Merged
merged 8 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions apps/server/src/migrations/mikro-orm/Migration20240430140106.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Migration } from '@mikro-orm/migrations-mongodb';

export class Migration20240430140106 extends Migration {
async up(): Promise<void> {
const superheroRoleUpdate = await this.driver.nativeUpdate(
'roles',
{ name: 'superhero' },
{
$addToSet: {
permissions: {
$each: ['USER_LOGIN_MIGRATION_ROLLBACK'],
MarvinOehlerkingCap marked this conversation as resolved.
Show resolved Hide resolved
},
},
}
);

if (superheroRoleUpdate.affectedRows > 0) {
console.info('Permission USER_LOGIN_MIGRATION_ROLLBACK was added to role superhero.');
}
}

async down(): Promise<void> {
const superheroRoleUpdate = await this.driver.nativeUpdate(
'roles',
{ name: 'superhero' },
{
$pull: {
permissions: {
$in: ['USER_LOGIN_MIGRATION_ROLLBACK'],
},
},
}
);

if (superheroRoleUpdate.affectedRows > 0) {
console.info('Rollback: Removed permission USER_LOGIN_MIGRATION_ROLLBACK from role superhero.');
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { EntityManager, ObjectId } from '@mikro-orm/mongodb';
import { AccountEntity } from '@modules/account/entity/account.entity';
import { ServerTestModule } from '@modules/server';
import { HttpStatus, INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { SchoolEntity, SystemEntity, User } from '@shared/domain/entity';
import { UserLoginMigrationEntity } from '@shared/domain/entity/user-login-migration.entity';
import {
cleanupCollections,
schoolEntityFactory,
systemEntityFactory,
TestApiClient,
UserAndAccountTestFactory,
userLoginMigrationFactory,
} from '@shared/testing';
import { Response } from 'supertest';

describe('UserLoginMigrationRollbackController (API)', () => {
let app: INestApplication;
let em: EntityManager;
let testApiClient: TestApiClient;

beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [ServerTestModule],
}).compile();

app = moduleRef.createNestApplication();
await app.init();
em = app.get(EntityManager);
testApiClient = new TestApiClient(app, '/user-login-migrations');
});

afterAll(async () => {
await app.close();
});

afterEach(async () => {
await cleanupCollections(em);
});

describe('[GET] /user-login-migrations', () => {
describe('when a user is rolled back', () => {
const setup = async () => {
const date: Date = new Date(2023, 5, 4);
const sourceSystem: SystemEntity = systemEntityFactory.withLdapConfig().buildWithId({ alias: 'SourceSystem' });
const targetSystem: SystemEntity = systemEntityFactory.withOauthConfig().buildWithId({ alias: 'SANIS' });
const school: SchoolEntity = schoolEntityFactory.buildWithId({
systems: [sourceSystem],
});
const userLoginMigration: UserLoginMigrationEntity = userLoginMigrationFactory.buildWithId({
school,
targetSystem,
sourceSystem,
startedAt: date,
mandatorySince: date,
closedAt: date,
finishedAt: date,
});
const { superheroAccount, superheroUser } = UserAndAccountTestFactory.buildSuperhero();
const { adminAccount, adminUser } = UserAndAccountTestFactory.buildAdmin({ school, externalId: 'externalId' });
adminUser.previousExternalId = 'previousExternalId';
adminUser.lastLoginSystemChange = date;

await em.persistAndFlush([
sourceSystem,
targetSystem,
school,
userLoginMigration,
adminAccount,
adminUser,
superheroAccount,
superheroUser,
]);
em.clear();

const loggedInClient = await testApiClient.login(superheroAccount);

return {
loggedInClient,
userLoginMigration,
migratedUser: adminUser,
migratedAccount: adminAccount,
};
};

it('should return the users migration', async () => {
const { loggedInClient, migratedUser, migratedAccount, userLoginMigration } = await setup();

const response: Response = await loggedInClient.post(`/users/${migratedUser.id}/rollback-migration`);

const revertedUser: User = await em.findOneOrFail(User, migratedUser.id);
const revertedAccount: AccountEntity = await em.findOneOrFail(AccountEntity, migratedAccount.id);

expect(response.status).toEqual(HttpStatus.NO_CONTENT);
expect(revertedUser.externalId).toEqual(migratedUser.previousExternalId);
expect(revertedUser.previousExternalId).toBeUndefined();
expect(revertedUser.lastLoginSystemChange).toBeUndefined();
expect(revertedUser.outdatedSince).toEqual(userLoginMigration.closedAt);
expect(revertedAccount.systemId?.toHexString()).toEqual(userLoginMigration.sourceSystem?.id);
});
});

describe('when unauthorized', () => {
it('should return Unauthorized', async () => {
const response: Response = await testApiClient.post(
`/users/${new ObjectId().toHexString()}/rollback-migration`
);

expect(response.status).toEqual(HttpStatus.UNAUTHORIZED);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export * from './request/user-login-migration-search.params';
export * from './request';
export * from './response/user-login-migration.response';
export * from './response/user-login-migration-search-list.response';
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { UserIdParams } from './user-id.params';
export { SchoolIdParams } from './school-id.params';
export { UserLoginMigrationSearchParams } from './user-login-migration-search.params';
export { UserLoginMigrationMandatoryParams } from './user-login-migration-mandatory.params';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { EntityId } from '@shared/domain/types';
import { IsMongoId } from 'class-validator';

export class UserIdParams {
@ApiProperty()
@IsMongoId()
userId!: EntityId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication';
import { Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common';
import {
ApiForbiddenResponse,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
ApiUnprocessableEntityResponse,
} from '@nestjs/swagger';
import { UserLoginMigrationRollbackUc } from '../uc';
import { UserIdParams } from './dto';

@ApiTags('UserLoginMigration Rollback')
@Controller('user-login-migrations')
@Authenticate('jwt')
export class UserLoginMigrationRollbackController {
constructor(private readonly userLoginMigrationRollbackUc: UserLoginMigrationRollbackUc) {}

@Post('/users/:userId/rollback-migration')
arnegns marked this conversation as resolved.
Show resolved Hide resolved
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Rollback a user from a user login migration' })
@ApiForbiddenResponse({ description: 'User is not allowed to access this resource' })
@ApiUnauthorizedResponse({ description: 'User is not logged in' })
@ApiNoContentResponse({ description: 'The user has been successfully rolled back' })
@ApiNotFoundResponse({ description: "The user's school has no migration" })
@ApiUnprocessableEntityResponse({ description: 'The user has not migrated yet' })
public async migrateUserLogin(
@CurrentUser() currentUser: ICurrentUser,
@Param() userIdParams: UserIdParams
): Promise<void> {
await this.userLoginMigrationRollbackUc.rollbackUser(currentUser.userId, userIdParams.userId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ import {
UserLoginMigrationUc,
} from '../uc';
import {
SchoolIdParams,
UserLoginMigrationMandatoryParams,
UserLoginMigrationResponse,
UserLoginMigrationSearchListResponse,
UserLoginMigrationSearchParams,
} from './dto';
import { Oauth2MigrationParams } from './dto/oauth2-migration.params';
import { SchoolIdParams } from './dto/request/school-id.params';
import { UserLoginMigrationMandatoryParams } from './dto/request/user-login-migration-mandatory.params';

@ApiTags('UserLoginMigration')
@Controller('user-login-migrations')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ export * from './school-migration-database-operation-failed.loggable-exception';
export * from './invalid-user-login-migration.loggable-exception';
export * from './identical-user-login-migration-system.loggable-exception';
export * from './moin-schule-system-not-found.loggable-exception';
export { UserNotMigratedLoggableException } from './user-not-migrated.loggable-exception';
export { UserMigrationRollbackSuccessfulLoggable } from './user-migration-rollback-successful.loggable';
export * from './debug';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { EntityId } from '@shared/domain/types';
import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger';

export class UserMigrationRollbackSuccessfulLoggable implements Loggable {
constructor(
private readonly userId?: EntityId,
private readonly externalId?: EntityId,
private readonly userLoginMigrationId?: EntityId
) {}

getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage {
return {
message: 'The user has been successfully rolled back from the migration.',
data: {
userId: this.userId,
externalId: this.externalId,
userLoginMigrationId: this.userLoginMigrationId,
},
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ObjectId } from '@mikro-orm/mongodb';
import { UserNotMigratedLoggableException } from './user-not-migrated.loggable-exception';

describe(UserNotMigratedLoggableException.name, () => {
describe('getLogMessage', () => {
const setup = () => {
const userId = new ObjectId().toHexString();

const exception = new UserNotMigratedLoggableException(userId);

return {
exception,
userId,
};
};

it('should return the correct log message', () => {
const { exception, userId } = setup();

const message = exception.getLogMessage();

expect(message).toEqual({
type: 'USER_NOT_MIGRATED',
message: 'The user has not migrated yet.',
stack: expect.any(String),
data: {
userId,
},
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { UnprocessableEntityException } from '@nestjs/common';
import { EntityId } from '@shared/domain/types';
import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger';

export class UserNotMigratedLoggableException extends UnprocessableEntityException implements Loggable {
constructor(private readonly userId?: EntityId) {
super();
}

getLogMessage(): LogMessage | ErrorLogMessage | ValidationErrorLogMessage {
return {
type: 'USER_NOT_MIGRATED',
message: 'The user has not migrated yet.',
stack: this.stack,
data: {
userId: this.userId,
},
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './school-migration.service';
export * from './migration-check.service';
export * from './user-login-migration.service';
export * from './user-login-migration-revert.service';
export { UserLoginMigrationRollbackService } from './user-login-migration-rollback.service';
Loading
Loading