Skip to content

Commit 2387511

Browse files
authored
Merge pull request #37 from GitAddRemote/feature/system-user
feat: introduce system user for automated changes
2 parents 3c83b2d + 1f45944 commit 2387511

16 files changed

Lines changed: 388 additions & 7 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddIsSystemUserColumn1764791773398 implements MigrationInterface {
4+
public async up(queryRunner: QueryRunner): Promise<void> {
5+
// Add is_system_user column to users table
6+
await queryRunner.query(
7+
`ALTER TABLE "user" ADD COLUMN "isSystemUser" boolean NOT NULL DEFAULT false`,
8+
);
9+
10+
// Create index for system user queries
11+
await queryRunner.query(
12+
`CREATE INDEX "IDX_user_is_system_user" ON "user" ("isSystemUser")`,
13+
);
14+
}
15+
16+
public async down(queryRunner: QueryRunner): Promise<void> {
17+
// Drop index
18+
await queryRunner.query(`DROP INDEX "IDX_user_is_system_user"`);
19+
20+
// Drop column
21+
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isSystemUser"`);
22+
}
23+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
import * as bcrypt from 'bcrypt';
3+
4+
export class SeedSystemUser1764791795973 implements MigrationInterface {
5+
public async up(queryRunner: QueryRunner): Promise<void> {
6+
// Generate an unusable password hash (system user should never authenticate)
7+
const unusablePassword = await bcrypt.hash(
8+
'SYSTEM_USER_NO_LOGIN_' + Math.random(),
9+
10,
10+
);
11+
12+
// Insert system user with ID = 1 (reserved)
13+
await queryRunner.query(
14+
`
15+
INSERT INTO "user" (
16+
"id",
17+
"username",
18+
"email",
19+
"password",
20+
"isActive",
21+
"isSystemUser"
22+
) VALUES (
23+
1,
24+
'station-system',
25+
'system@station.internal',
26+
$1,
27+
true,
28+
true
29+
)
30+
ON CONFLICT (id) DO NOTHING
31+
`,
32+
[unusablePassword],
33+
);
34+
35+
// Reset the sequence to start from 2 for normal users
36+
await queryRunner.query(
37+
`SELECT setval(pg_get_serial_sequence('user', 'id'), (SELECT MAX(id) FROM "user"), true)`,
38+
);
39+
}
40+
41+
public async down(queryRunner: QueryRunner): Promise<void> {
42+
// Remove system user
43+
await queryRunner.query(`DELETE FROM "user" WHERE "id" = 1`);
44+
}
45+
}

backend/src/modules/auth/auth.service.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Test, TestingModule } from '@nestjs/testing';
22
import { AuthService } from './auth.service';
33
import { UsersService } from '../users/users.service';
4+
import { SystemUserService } from '../users/system-user.service';
45
import { JwtService } from '@nestjs/jwt';
56
import { ConfigService } from '@nestjs/config';
67
import { getRepositoryToken } from '@nestjs/typeorm';
@@ -17,6 +18,7 @@ describe('AuthService - Password Reset', () => {
1718
username: 'testuser',
1819
email: 'test@example.com',
1920
password: '$2b$10$hashedpassword',
21+
isSystemUser: false,
2022
};
2123

2224
const mockUsersService = {
@@ -25,6 +27,11 @@ describe('AuthService - Password Reset', () => {
2527
updatePassword: jest.fn(),
2628
};
2729

30+
const mockSystemUserService = {
31+
getSystemUserId: jest.fn(),
32+
isSystemUser: jest.fn(),
33+
};
34+
2835
const mockPasswordResetRepository = {
2936
save: jest.fn(),
3037
findOne: jest.fn(),
@@ -57,6 +64,10 @@ describe('AuthService - Password Reset', () => {
5764
provide: UsersService,
5865
useValue: mockUsersService,
5966
},
67+
{
68+
provide: SystemUserService,
69+
useValue: mockSystemUserService,
70+
},
6071
{
6172
provide: JwtService,
6273
useValue: mockJwtService,

backend/src/modules/auth/auth.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { JwtService } from '@nestjs/jwt';
88
import { InjectRepository } from '@nestjs/typeorm';
99
import { Repository } from 'typeorm';
1010
import { UsersService } from '../users/users.service';
11+
import { SystemUserService } from '../users/system-user.service';
1112
import * as bcrypt from 'bcrypt';
1213
import * as crypto from 'crypto';
1314
import { User } from '../users/user.entity';
@@ -25,6 +26,7 @@ export class AuthService {
2526

2627
constructor(
2728
private usersService: UsersService,
29+
private systemUserService: SystemUserService,
2830
private jwtService: JwtService,
2931
private configService: ConfigService,
3032
@InjectRepository(RefreshToken)
@@ -37,6 +39,14 @@ export class AuthService {
3739
const user = await this.usersService.findOne(username);
3840
const trimmedPass = pass.trim();
3941

42+
// Block system user from authentication
43+
if (user && user.isSystemUser) {
44+
this.logger.warn(
45+
`System user attempted to authenticate: ${username}. This is not allowed.`,
46+
);
47+
return null;
48+
}
49+
4050
const hashToCompare = user?.password ?? this.dummyHash;
4151
const isMatch = await bcrypt.compare(trimmedPass, hashToCompare);
4252

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { SystemUserService } from './system-user.service';
4+
import { User } from './user.entity';
5+
6+
describe('SystemUserService', () => {
7+
let service: SystemUserService;
8+
9+
const mockSystemUser = {
10+
id: 1,
11+
username: 'station-system',
12+
email: 'system@station.internal',
13+
isSystemUser: true,
14+
isActive: true,
15+
};
16+
17+
const mockRepository = {
18+
findOne: jest.fn(),
19+
create: jest.fn(),
20+
save: jest.fn(),
21+
};
22+
23+
beforeEach(async () => {
24+
const module: TestingModule = await Test.createTestingModule({
25+
providers: [
26+
SystemUserService,
27+
{
28+
provide: getRepositoryToken(User),
29+
useValue: mockRepository,
30+
},
31+
],
32+
}).compile();
33+
34+
service = module.get<SystemUserService>(SystemUserService);
35+
jest.clearAllMocks();
36+
});
37+
38+
it('should be defined', () => {
39+
expect(service).toBeDefined();
40+
});
41+
42+
describe('onModuleInit', () => {
43+
it('should cache system user ID at startup', async () => {
44+
mockRepository.findOne.mockResolvedValue(mockSystemUser);
45+
46+
await service.onModuleInit();
47+
48+
expect(mockRepository.findOne).toHaveBeenCalledWith({
49+
where: { id: 1, isSystemUser: true },
50+
select: ['id'],
51+
});
52+
expect(service.getSystemUserId()).toBe(1);
53+
});
54+
55+
it('should throw error if system user is missing', async () => {
56+
// Store original NODE_ENV
57+
const originalNodeEnv = process.env.NODE_ENV;
58+
59+
// Set NODE_ENV to non-test value to prevent auto-creation
60+
process.env.NODE_ENV = 'production';
61+
62+
mockRepository.findOne.mockResolvedValue(null);
63+
64+
await expect(service.onModuleInit()).rejects.toThrow(
65+
'System user not found! Run migrations to seed system user: pnpm migration:run',
66+
);
67+
68+
// Restore original NODE_ENV
69+
process.env.NODE_ENV = originalNodeEnv;
70+
});
71+
72+
it('should auto-create system user in test environment', async () => {
73+
// Store original NODE_ENV
74+
const originalNodeEnv = process.env.NODE_ENV;
75+
76+
// Ensure we're in test environment
77+
process.env.NODE_ENV = 'test';
78+
79+
mockRepository.findOne.mockResolvedValue(null);
80+
mockRepository.create.mockReturnValue(mockSystemUser);
81+
mockRepository.save.mockResolvedValue(mockSystemUser);
82+
83+
await service.onModuleInit();
84+
85+
expect(mockRepository.create).toHaveBeenCalledWith(
86+
expect.objectContaining({
87+
id: 1,
88+
username: 'station-system',
89+
email: 'system@station.internal',
90+
isActive: true,
91+
isSystemUser: true,
92+
}),
93+
);
94+
expect(mockRepository.save).toHaveBeenCalled();
95+
expect(service.getSystemUserId()).toBe(1);
96+
97+
// Restore original NODE_ENV
98+
process.env.NODE_ENV = originalNodeEnv;
99+
});
100+
});
101+
102+
describe('getSystemUserId', () => {
103+
it('should return cached system user ID', async () => {
104+
mockRepository.findOne.mockResolvedValue(mockSystemUser);
105+
await service.onModuleInit();
106+
107+
expect(service.getSystemUserId()).toBe(1);
108+
});
109+
110+
it('should throw error if not initialized', () => {
111+
expect(() => service.getSystemUserId()).toThrow(
112+
'System user not initialized. Ensure SystemUserService.onModuleInit() has been called.',
113+
);
114+
});
115+
});
116+
117+
describe('isSystemUser', () => {
118+
it('should return true for system user ID', async () => {
119+
mockRepository.findOne.mockResolvedValue(mockSystemUser);
120+
await service.onModuleInit();
121+
122+
expect(service.isSystemUser(1)).toBe(true);
123+
});
124+
125+
it('should return false for non-system user ID', async () => {
126+
mockRepository.findOne.mockResolvedValue(mockSystemUser);
127+
await service.onModuleInit();
128+
129+
expect(service.isSystemUser(2)).toBe(false);
130+
expect(service.isSystemUser(100)).toBe(false);
131+
});
132+
});
133+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { User } from './user.entity';
5+
import * as bcrypt from 'bcrypt';
6+
7+
@Injectable()
8+
export class SystemUserService implements OnModuleInit {
9+
private readonly logger = new Logger(SystemUserService.name);
10+
private systemUserId: number | null = null;
11+
private readonly SYSTEM_USER_ID = 1; // Reserved ID for system user
12+
13+
constructor(
14+
@InjectRepository(User)
15+
private usersRepository: Repository<User>,
16+
) {}
17+
18+
async onModuleInit(): Promise<void> {
19+
// Cache system user ID at startup
20+
this.logger.log('Initializing system user service...');
21+
22+
let systemUser = await this.usersRepository.findOne({
23+
where: { id: this.SYSTEM_USER_ID, isSystemUser: true },
24+
select: ['id'],
25+
});
26+
27+
// In test environment, auto-create system user if missing
28+
if (!systemUser && process.env.NODE_ENV === 'test') {
29+
this.logger.warn(
30+
'System user not found in test environment - creating automatically',
31+
);
32+
systemUser = await this.createSystemUser();
33+
}
34+
35+
if (!systemUser) {
36+
throw new Error(
37+
'System user not found! Run migrations to seed system user: pnpm migration:run',
38+
);
39+
}
40+
41+
this.systemUserId = systemUser.id;
42+
this.logger.log(`System user initialized with ID: ${this.systemUserId}`);
43+
}
44+
45+
private async createSystemUser(): Promise<User> {
46+
const unusablePassword = await bcrypt.hash(
47+
'SYSTEM_USER_NO_LOGIN_' + Math.random(),
48+
10,
49+
);
50+
51+
const systemUser = this.usersRepository.create({
52+
id: this.SYSTEM_USER_ID,
53+
username: 'station-system',
54+
email: 'system@station.internal',
55+
password: unusablePassword,
56+
isActive: true,
57+
isSystemUser: true,
58+
});
59+
60+
return await this.usersRepository.save(systemUser);
61+
}
62+
63+
getSystemUserId(): number {
64+
if (!this.systemUserId) {
65+
throw new Error(
66+
'System user not initialized. Ensure SystemUserService.onModuleInit() has been called.',
67+
);
68+
}
69+
return this.systemUserId;
70+
}
71+
72+
isSystemUser(userId: number): boolean {
73+
return userId === this.SYSTEM_USER_ID;
74+
}
75+
}

backend/src/modules/users/user.entity.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
1+
import {
2+
Entity,
3+
Column,
4+
PrimaryGeneratedColumn,
5+
OneToMany,
6+
Index,
7+
} from 'typeorm';
28
import { UserOrganizationRole } from '../user-organization-roles/user-organization-role.entity';
39

410
@Entity()
@@ -18,6 +24,10 @@ export class User {
1824
@Column({ default: true })
1925
isActive!: boolean;
2026

27+
@Column({ default: false })
28+
@Index()
29+
isSystemUser!: boolean;
30+
2131
@Column({ length: 100, nullable: true })
2232
firstName?: string;
2333

backend/src/modules/users/users.module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
33
import { UsersService } from './users.service';
44
import { UsersController } from './users.controller';
55
import { User } from './user.entity';
6+
import { SystemUserService } from './system-user.service';
67

78
@Module({
89
imports: [TypeOrmModule.forFeature([User])],
9-
providers: [UsersService],
10+
providers: [UsersService, SystemUserService],
1011
controllers: [UsersController],
11-
exports: [UsersService],
12+
exports: [UsersService, SystemUserService],
1213
})
1314
export class UsersModule {}

0 commit comments

Comments
 (0)