690419:1411 feat: update CI/CD to use SSH key authentication #05
This commit is contained in:
@@ -1,3 +1,14 @@
|
||||
// ADR-021 Clarify Q2 (C1): Mock Redlock ก่อน import service
|
||||
// ใช้ module-level mock เพื่อบังคับให้ constructor `new Redlock(...)` ในการสร้าง service
|
||||
const mockRedlockAcquire = jest.fn();
|
||||
const mockRedlockRelease = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mock('redlock', () =>
|
||||
jest.fn().mockImplementation(() => ({
|
||||
acquire: mockRedlockAcquire,
|
||||
}))
|
||||
);
|
||||
|
||||
import { ConflictException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { WorkflowEngineService } from './workflow-engine.service';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
@@ -12,9 +23,12 @@ import { Attachment } from '../../common/file-storage/entities/attachment.entity
|
||||
import { WorkflowDslService } from './workflow-dsl.service';
|
||||
import { WorkflowEventService } from './workflow-event.service';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { NotFoundException } from '../../common/exceptions';
|
||||
import { NotFoundException, WorkflowException } from '../../common/exceptions';
|
||||
import { CreateWorkflowDefinitionDto } from './dto/create-workflow-definition.dto';
|
||||
|
||||
// Token ของ @nestjs-modules/ioredis — default Redis connection
|
||||
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
|
||||
|
||||
describe('WorkflowEngineService', () => {
|
||||
let service: WorkflowEngineService;
|
||||
let defRepo: Repository<WorkflowDefinition>;
|
||||
@@ -60,6 +74,12 @@ describe('WorkflowEngineService', () => {
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// ADR-021 C1: default Redlock behavior = acquire สำเร็จ
|
||||
mockRedlockAcquire.mockReset().mockResolvedValue({
|
||||
release: mockRedlockRelease,
|
||||
});
|
||||
mockRedlockRelease.mockClear();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkflowEngineService,
|
||||
@@ -105,6 +125,13 @@ describe('WorkflowEngineService', () => {
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
// ADR-021 C1: Redis mock สำหรับ @InjectRedis()
|
||||
{
|
||||
provide: DEFAULT_REDIS_TOKEN,
|
||||
useValue: {
|
||||
// ไม่จำเป็นต้องมี method จริง เพราะ Redlock ถูก mock แล้ว
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -113,8 +140,6 @@ describe('WorkflowEngineService', () => {
|
||||
instanceRepo = module.get(getRepositoryToken(WorkflowInstance));
|
||||
dslService = module.get(WorkflowDslService);
|
||||
eventService = module.get(WorkflowEventService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -227,18 +252,27 @@ describe('WorkflowEngineService', () => {
|
||||
const attachmentPublicIds = ['att-123', 'att-456'];
|
||||
const mockInstance = {
|
||||
id: instanceId,
|
||||
currentState: 'PENDING',
|
||||
currentState: 'PENDING_REVIEW', // ADR-021 C3: allowed upload state
|
||||
status: WorkflowStatus.ACTIVE,
|
||||
definition: { compiled: mockCompiledWorkflow },
|
||||
context: { some: 'data' },
|
||||
};
|
||||
|
||||
// C3 pre-check ดึง instance จาก instanceRepo.findOne (ไม่ใช่ queryRunner)
|
||||
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
||||
id: instanceId,
|
||||
currentState: 'PENDING_REVIEW',
|
||||
});
|
||||
|
||||
// Mock the history object with an ID
|
||||
const mockHistory = { id: 'history-123' };
|
||||
mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance);
|
||||
|
||||
// Mock save to return the history object when called with any entity
|
||||
mockQueryRunner.manager.save.mockResolvedValue(mockHistory);
|
||||
// C2: update ต้องรายงาน affected = attachmentPublicIds.length
|
||||
mockQueryRunner.manager.update.mockResolvedValue({
|
||||
affected: attachmentPublicIds.length,
|
||||
});
|
||||
|
||||
mockDslService.evaluate.mockReturnValue({
|
||||
nextState: 'APPROVED',
|
||||
events: [],
|
||||
@@ -253,9 +287,15 @@ describe('WorkflowEngineService', () => {
|
||||
attachmentPublicIds
|
||||
);
|
||||
|
||||
// C2: where clause ต้องมี guards ครบ 3 ชั้น
|
||||
expect(mockQueryRunner.manager.update).toHaveBeenCalledWith(
|
||||
Attachment,
|
||||
{ publicId: In(attachmentPublicIds) },
|
||||
{
|
||||
publicId: In(attachmentPublicIds),
|
||||
isTemporary: false,
|
||||
uploadedByUserId: 1,
|
||||
workflowHistoryId: null,
|
||||
},
|
||||
{ workflowHistoryId: 'history-123' }
|
||||
);
|
||||
});
|
||||
@@ -317,5 +357,174 @@ describe('WorkflowEngineService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// ADR-021 T031a: Clarify Session 2026-04-19 Amendments
|
||||
// ============================================================
|
||||
describe('ADR-021 Clarify Q1+Q2 (T031a) — state check, Redlock, guards', () => {
|
||||
const attachmentPublicIds = ['att-1'];
|
||||
|
||||
it('C3: should throw ConflictException (409) when uploading in APPROVED state', async () => {
|
||||
// Arrange: currentState = APPROVED (terminal, ไม่อยู่ใน UPLOAD_ALLOWED_STATES)
|
||||
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
||||
id: 'inst-1',
|
||||
currentState: 'APPROVED',
|
||||
});
|
||||
|
||||
// Act + Assert
|
||||
await expect(
|
||||
service.processTransition(
|
||||
'inst-1',
|
||||
'APPROVE',
|
||||
1,
|
||||
undefined,
|
||||
{},
|
||||
attachmentPublicIds
|
||||
)
|
||||
).rejects.toThrow(ConflictException);
|
||||
|
||||
// Redlock ต้องไม่ถูกเรียก (pre-check บล็อกก่อน)
|
||||
expect(mockRedlockAcquire).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('C3: should throw ConflictException (409) when uploading in REJECTED state', async () => {
|
||||
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
||||
id: 'inst-1',
|
||||
currentState: 'REJECTED',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.processTransition(
|
||||
'inst-1',
|
||||
'APPROVE',
|
||||
1,
|
||||
undefined,
|
||||
{},
|
||||
attachmentPublicIds
|
||||
)
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('C3: should skip state check when attachmentPublicIds is empty', async () => {
|
||||
// ถ้าไม่มี attachment ไม่ต้องตรวจ state — transition ในสถานะไหนก็ได้
|
||||
const mockInstance = {
|
||||
id: 'inst-1',
|
||||
currentState: 'DRAFT',
|
||||
status: WorkflowStatus.ACTIVE,
|
||||
definition: { compiled: mockCompiledWorkflow },
|
||||
context: {},
|
||||
};
|
||||
mockQueryRunner.manager.findOne.mockResolvedValue(mockInstance);
|
||||
mockQueryRunner.manager.save.mockResolvedValue({ id: 'history-1' });
|
||||
mockDslService.evaluate.mockReturnValue({
|
||||
nextState: 'PENDING',
|
||||
events: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.processTransition('inst-1', 'SUBMIT', 1)
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// pre-check ต้องไม่ถูกเรียก (ไม่มี attachments)
|
||||
expect(instanceRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('C1: should throw ServiceUnavailableException (503) when Redlock acquire fails', async () => {
|
||||
// Arrange: state check ผ่าน
|
||||
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
||||
id: 'inst-1',
|
||||
currentState: 'PENDING_REVIEW',
|
||||
});
|
||||
// Redlock ล้มเหลว — Redis ล่ม / ไม่สามารถ acquire หลัง retry 3 ครั้ง
|
||||
mockRedlockAcquire.mockRejectedValue(
|
||||
new Error('ExecutionError: unable to achieve quorum')
|
||||
);
|
||||
|
||||
// Act + Assert
|
||||
await expect(
|
||||
service.processTransition(
|
||||
'inst-1',
|
||||
'APPROVE',
|
||||
1,
|
||||
undefined,
|
||||
{},
|
||||
attachmentPublicIds
|
||||
)
|
||||
).rejects.toThrow(ServiceUnavailableException);
|
||||
|
||||
// DB transaction ต้องไม่เคยเริ่ม (fail-closed before DB work)
|
||||
expect(mockQueryRunner.startTransaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('C2: should rollback and throw when update.affected < expected (temp/foreign attachment)', async () => {
|
||||
// Arrange: state ผ่าน, Redlock ผ่าน, DB transaction เดินไปถึง update
|
||||
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
||||
id: 'inst-1',
|
||||
currentState: 'PENDING_APPROVAL',
|
||||
});
|
||||
mockQueryRunner.manager.findOne.mockResolvedValue({
|
||||
id: 'inst-1',
|
||||
currentState: 'PENDING_APPROVAL',
|
||||
status: WorkflowStatus.ACTIVE,
|
||||
definition: { compiled: mockCompiledWorkflow },
|
||||
context: {},
|
||||
});
|
||||
mockQueryRunner.manager.save.mockResolvedValue({ id: 'history-999' });
|
||||
mockDslService.evaluate.mockReturnValue({
|
||||
nextState: 'APPROVED',
|
||||
events: [],
|
||||
});
|
||||
// affected < expected — แปลว่ามีไฟล์บางไฟล์ temp / ของคนอื่น / ผูกไปแล้ว
|
||||
mockQueryRunner.manager.update.mockResolvedValue({ affected: 1 });
|
||||
|
||||
await expect(
|
||||
service.processTransition(
|
||||
'inst-1',
|
||||
'APPROVE',
|
||||
1,
|
||||
undefined,
|
||||
{},
|
||||
['att-1', 'att-2', 'att-3'] // ขอ 3 ไฟล์ แต่ affected = 1
|
||||
)
|
||||
).rejects.toThrow(WorkflowException);
|
||||
|
||||
// ต้อง rollback
|
||||
expect(mockQueryRunner.rollbackTransaction).toHaveBeenCalled();
|
||||
// ต้องไม่ commit
|
||||
expect(mockQueryRunner.commitTransaction).not.toHaveBeenCalled();
|
||||
// ต้อง release Redlock
|
||||
expect(mockRedlockRelease).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('C1: should release Redlock even when transition succeeds', async () => {
|
||||
(instanceRepo.findOne as jest.Mock).mockResolvedValue({
|
||||
id: 'inst-1',
|
||||
currentState: 'PENDING_REVIEW',
|
||||
});
|
||||
mockQueryRunner.manager.findOne.mockResolvedValue({
|
||||
id: 'inst-1',
|
||||
currentState: 'PENDING_REVIEW',
|
||||
status: WorkflowStatus.ACTIVE,
|
||||
definition: { compiled: mockCompiledWorkflow },
|
||||
context: {},
|
||||
});
|
||||
mockQueryRunner.manager.save.mockResolvedValue({ id: 'history-1' });
|
||||
mockQueryRunner.manager.update.mockResolvedValue({ affected: 1 });
|
||||
mockDslService.evaluate.mockReturnValue({
|
||||
nextState: 'APPROVED',
|
||||
events: [],
|
||||
});
|
||||
|
||||
await service.processTransition('inst-1', 'APPROVE', 1, undefined, {}, [
|
||||
'att-1',
|
||||
]);
|
||||
|
||||
expect(mockRedlockAcquire).toHaveBeenCalledWith(
|
||||
['lock:wf:transition:inst-1'],
|
||||
10000
|
||||
);
|
||||
expect(mockRedlockRelease).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
// File: src/modules/workflow-engine/workflow-engine.service.ts
|
||||
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Inject,
|
||||
Logger,
|
||||
ConflictException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import type { Cache } from 'cache-manager';
|
||||
import { NotFoundException, WorkflowException } from '../../common/exceptions';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
// ADR-021 Clarify Q2: Redis Redlock for transition Fail-closed (Retry 3x → 503)
|
||||
import { InjectRedis } from '@nestjs-modules/ioredis';
|
||||
import Redis from 'ioredis';
|
||||
import Redlock, { Lock } from 'redlock';
|
||||
// Entities
|
||||
import { WorkflowDefinition } from './entities/workflow-definition.entity';
|
||||
import { WorkflowHistory } from './entities/workflow-history.entity';
|
||||
@@ -44,6 +54,13 @@ export interface TransitionResult {
|
||||
@Injectable()
|
||||
export class WorkflowEngineService {
|
||||
private readonly logger = new Logger(WorkflowEngineService.name);
|
||||
private readonly redlock: Redlock;
|
||||
|
||||
// ADR-021 Clarify Q1: สถานะ workflow ที่อนุญาตให้อัปโหลด Step-attachment
|
||||
private static readonly UPLOAD_ALLOWED_STATES = new Set<string>([
|
||||
'PENDING_REVIEW',
|
||||
'PENDING_APPROVAL',
|
||||
]);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkflowDefinition)
|
||||
@@ -58,8 +75,18 @@ export class WorkflowEngineService {
|
||||
private readonly dslService: WorkflowDslService,
|
||||
private readonly eventService: WorkflowEventService, // [NEW] Inject Service
|
||||
private readonly dataSource: DataSource, // ใช้สำหรับ Transaction
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache // ADR-021 T024: History cache
|
||||
) {}
|
||||
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache, // ADR-021 T024: History cache
|
||||
@InjectRedis() private readonly redis: Redis // ADR-021 Clarify Q2: Redlock
|
||||
) {
|
||||
// ADR-021 Clarify Q2 (C1): Redlock Fail-closed
|
||||
// Retry 3 ครั้ง × 500ms เพิ่ม jitter → ถ้ายังไม่ได้ throw HTTP 503
|
||||
this.redlock = new Redlock([this.redis], {
|
||||
driftFactor: 0.01,
|
||||
retryCount: 3,
|
||||
retryDelay: 500,
|
||||
retryJitter: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// [PART 1] Definition Management (Phase 6A)
|
||||
@@ -305,6 +332,53 @@ export class WorkflowEngineService {
|
||||
// ADR-021: publicIds ของไฟล์แนบประจำ Step นี้ (Two-Phase upload ก่อนแล้ว)
|
||||
attachmentPublicIds?: string[]
|
||||
) {
|
||||
const hasAttachments =
|
||||
attachmentPublicIds !== undefined && attachmentPublicIds.length > 0;
|
||||
|
||||
// ==============================================================
|
||||
// ADR-021 Clarify Q1 (C3): ตรวจสถานะก่อน acquire Redlock
|
||||
// อนุญาตให้แนบไฟล์เฉพาะในสถานะ PENDING_REVIEW / PENDING_APPROVAL
|
||||
// ==============================================================
|
||||
if (hasAttachments) {
|
||||
const instancePreCheck = await this.instanceRepo.findOne({
|
||||
where: { id: instanceId },
|
||||
select: ['id', 'currentState'],
|
||||
});
|
||||
if (!instancePreCheck) {
|
||||
throw new NotFoundException('Workflow Instance', instanceId);
|
||||
}
|
||||
if (
|
||||
!WorkflowEngineService.UPLOAD_ALLOWED_STATES.has(
|
||||
instancePreCheck.currentState
|
||||
)
|
||||
) {
|
||||
throw new ConflictException({
|
||||
userMessage: 'ไม่สามารถอัปโหลดไฟล์ในสถานะนี้ได้',
|
||||
recoveryAction:
|
||||
'อนุญาตเฉพาะสถานะ PENDING_REVIEW หรือ PENDING_APPROVAL เท่านั้น',
|
||||
currentState: instancePreCheck.currentState,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// ADR-021 Clarify Q2 (C1): Acquire Redlock (Fail-closed)
|
||||
// Retry 3x × 500ms + jitter → ถ้ายังไม่ได้ throw HTTP 503
|
||||
// ==============================================================
|
||||
const lockKey = `lock:wf:transition:${instanceId}`;
|
||||
let lock: Lock;
|
||||
try {
|
||||
lock = await this.redlock.acquire([lockKey], 10000); // 10s TTL
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Redlock acquire failed after retries for ${instanceId}: ${(err as Error).message}`
|
||||
);
|
||||
throw new ServiceUnavailableException({
|
||||
userMessage: 'ระบบยุ่งชั่วคราว กรุณาลองใหม่ภายหลัง',
|
||||
recoveryAction: 'รอสักครู่แล้วลองใหม่',
|
||||
});
|
||||
}
|
||||
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
@@ -375,13 +449,38 @@ export class WorkflowEngineService {
|
||||
});
|
||||
const savedHistory = await queryRunner.manager.save(history);
|
||||
|
||||
// ADR-021: ผูกไฟล์แนบประจำ Step นี้ (ทำในตัว Transaction เดียวกัน)
|
||||
if (attachmentPublicIds && attachmentPublicIds.length > 0) {
|
||||
await queryRunner.manager.update(
|
||||
// ==============================================================
|
||||
// ADR-021 (C2): Link attachments พร้อม guard 3 ชั้น
|
||||
// 1. isTemporary = false — Two-Phase commit แล้ว (ADR-016)
|
||||
// 2. uploadedByUserId = userId — ownership check (กัน attach ไฟล์คนอื่น)
|
||||
// 3. workflowHistoryId IS NULL — ยังไม่เคยผูกกับ step อื่น
|
||||
// ==============================================================
|
||||
if (hasAttachments) {
|
||||
const updateResult = await queryRunner.manager.update(
|
||||
Attachment,
|
||||
{ publicId: In(attachmentPublicIds) },
|
||||
{
|
||||
publicId: In(attachmentPublicIds),
|
||||
isTemporary: false,
|
||||
uploadedByUserId: userId,
|
||||
workflowHistoryId: null,
|
||||
},
|
||||
{ workflowHistoryId: savedHistory.id }
|
||||
);
|
||||
|
||||
const expected = attachmentPublicIds.length;
|
||||
const actual = updateResult.affected ?? 0;
|
||||
if (actual !== expected) {
|
||||
throw new WorkflowException(
|
||||
'INVALID_ATTACHMENTS',
|
||||
`Attachment link mismatch: expected ${expected}, linked ${actual}`,
|
||||
'ไฟล์แนบบางไฟล์ไม่สามารถผูกกับขั้นตอนนี้ได้',
|
||||
[
|
||||
'ตรวจสอบว่าไฟล์อัปโหลดสำเร็จ (ไม่ใช่ temp)',
|
||||
'ตรวจสอบว่าคุณเป็นเจ้าของไฟล์ทุกไฟล์',
|
||||
'ตรวจสอบว่าไฟล์ยังไม่เคยถูกผูกกับ step อื่น',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
@@ -426,6 +525,14 @@ export class WorkflowEngineService {
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
// ADR-021 C1: ปล่อย Redlock เสมอ (non-blocking หาก release ผิดพลาด)
|
||||
lock.release().catch((e: unknown) => {
|
||||
this.logger.warn(
|
||||
`Redlock release failed for ${instanceId} (may have expired): ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user