Skip to content
Open
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
69 changes: 69 additions & 0 deletions src/app/classes/release-tracks/snapshot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { ReleaseTrackSnapshot } from './snapshot';

describe('ReleaseTrackSnapshot', () => {
it('should preserve candidate and staged display fields', () => {
const snapshot = new ReleaseTrackSnapshot({
candidates: [
{
object_ref: 'attack-pattern--candidate',
object_modified: '2026-01-01T00:00:00.000Z',
object_status: 'work-in-progress',
attack_id: 'T1234',
name: 'Candidate Technique',
description: 'Candidate description',
modified_by_user: {
id: 'user-account--candidate',
username: 'candidateuser',
name: 'Candidate Reviewer',
},
},
],
staged: [
{
object_ref: 'attack-pattern--staged',
object_modified: '2026-01-02T00:00:00.000Z',
object_status: 'reviewed',
attack_id: 'T5678',
name: 'Staged Technique',
description: 'Staged description',
modified_by_user: {
id: 'user-account--staged',
username: 'stageduser',
name: 'Staged Reviewer',
},
},
],
members: [
{
object_ref: 'attack-pattern--member',
object_modified: '2026-01-03T00:00:00.000Z',
attack_id: 'T9999',
name: 'Member Technique',
},
],
});

expect(snapshot.candidates?.[0]).toEqual(
expect.objectContaining({
attack_id: 'T1234',
name: 'Candidate Technique',
description: 'Candidate description',
modified_by_user: expect.objectContaining({
name: 'Candidate Reviewer',
}),
})
);
expect(snapshot.staged?.[0]).toEqual(
expect.objectContaining({
attack_id: 'T5678',
name: 'Staged Technique',
description: 'Staged description',
modified_by_user: expect.objectContaining({
name: 'Staged Reviewer',
}),
})
);
expect(snapshot.members[0]).not.toHaveProperty('attack_id');
expect(snapshot.members[0]).not.toHaveProperty('name');
});
});
2 changes: 2 additions & 0 deletions src/app/classes/release-tracks/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export class ReleaseTrackSnapshot {

if ('staged' in raw && Array.isArray(raw.staged)) {
this.staged = raw.staged.map((s: any) => ({
...s,
object_ref: s.object_ref,
object_modified: new Date(s.object_modified),
object_status: s.object_status,
Expand All @@ -124,6 +125,7 @@ export class ReleaseTrackSnapshot {

if ('candidates' in raw && Array.isArray(raw.candidates)) {
this.candidates = raw.candidates.map((c: any) => ({
...c,
object_ref: c.object_ref,
object_modified: new Date(c.object_modified),
object_status: c.object_status,
Expand Down
18 changes: 16 additions & 2 deletions src/app/classes/release-tracks/tiers.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import { WorkflowStatusType } from 'src/app/utils/types';

export interface TierEntryModifiedByUser {
id?: string;
username?: string;
displayName?: string;
name?: string;
}

export interface TierEntryDisplayFields {
attack_id?: string;
name?: string;
description?: string;
modified_by_user?: TierEntryModifiedByUser;
}

export interface MemberEntry {
object_ref: string;
object_modified: Date;
}

export interface StagedEntry {
export interface StagedEntry extends TierEntryDisplayFields {
object_ref: string;
object_modified: Date;
object_status: WorkflowStatusType;
object_staged_at: Date;
object_staged_by: string;
}

export interface CandidateEntry {
export interface CandidateEntry extends TierEntryDisplayFields {
object_ref: string;
object_modified: Date;
object_status: WorkflowStatusType;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,19 @@
<div class="stat">
<div class="stat-label">CANDIDATES</div>
<div class="stat-value candidates-stat-value">
{{ track.candidates?.length || 'TODO' }}
{{ track.stats?.candidates ?? 0 }}
</div>
</div>
<div class="stat">
<div class="stat-label">STAGED</div>
<div class="stat-value staged-stat-value">
{{ track.staged?.length || 'TODO' }}
{{ track.stats?.staged ?? 0 }}
</div>
</div>
<div class="stat">
<div class="stat-label">MEMBERS</div>
<div class="stat-value">
{{ track.members?.length || 'TODO' }}
{{ track.stats?.members ?? 0 }}
</div>
</div>
</ng-container>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@
</mat-card-header>

<mat-card-content *ngIf="showDescription">
<p class="object-description">
{{ description }}
</p>
<markdown class="object-description" [data]="description"></markdown>
</mat-card-content>

<mat-card-actions>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,29 @@
}

.object-description {
display: -webkit-box;
display: block;
min-height: 30px;
margin: 0;
font-size: 14px;
font-weight: 500;
line-height: 22px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;

::ng-deep p {
display: -webkit-box;
overflow: hidden;
margin: 0;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}

::ng-deep :last-child {
margin-bottom: 0;
}

::ng-deep a {
font-weight: 700;
}

.light & {
color: colors.on-color(light);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { ReleaseTrackObjectCardComponent } from './release-track-object-card.component';

describe('ReleaseTrackObjectCardComponent', () => {
let component: ReleaseTrackObjectCardComponent;

beforeEach(() => {
component = new ReleaseTrackObjectCardComponent();
});

it('should show tier entry display fields when available', () => {
component.item = {
object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc',
object_modified: '2026-01-01T00:00:00.000Z',
attack_id: 'T1234',
name: 'Technique Name',
description: 'Technique description\n\nAdditional details.',
modified_by_user: {
id: 'user-account--1234',
username: 'reviewer1',
displayName: 'Review User',
name: 'Review User',
},
};

expect(component.title).toBe('Technique Name');
expect(component.subtitle).toBe('T1234');
expect(component.description).toBe('Technique description');
expect(component.modifiedByName).toBe('Review User');
});

it('should use username when modified by user has no display name', () => {
component.item = {
object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc',
modified_by_user: {
username: 'reviewer1',
},
};

expect(component.modifiedByName).toBe('reviewer1');
});

it('should preserve markdown syntax in descriptions for rendering', () => {
component.item = {
object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc',
description: '**Markdown** [description](https://example.com)',
};

expect(component.description).toBe(
'**Markdown** [description](https://example.com)'
);
});

it('should prefer display name over username and name', () => {
component.item = {
object_ref: 'attack-pattern--12345678-1234-1234-1234-123456789abc',
modified_by_user: {
username: 'releaseuser',
displayName: 'Release Reviewer',
name: 'Fallback Name',
},
};

expect(component.modifiedByName).toBe('Release Reviewer');
});

it('should prefer modified by user info over staged identity ids', () => {
component.item = {
object_ref: 'attack-pattern--990e4d99-5c6f-4f34-b6f0-7221c55f39cb',
object_modified: '2026-04-22T17:57:26.218Z',
object_status: 'reviewed',
object_staged_at: '2026-06-24T19:50:19.859Z',
object_staged_by: 'identity--00000000-0000-4000-8000-000000000001',
attack_id: 'T1640.001',
name: '0 New Sub',
modified_by_user: {
id: 'identity--00000000-0000-4000-8000-000000000001',
username: 'releaseuser',
displayName: 'Release Reviewer',
name: 'Release Reviewer',
},
};

expect(component.modifiedByName).toBe('Release Reviewer');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import moment from 'moment';
import { MarkdownModule } from 'ngx-markdown';
import { StixTypeToAttackType } from 'src/app/utils/type-mappings';
import { StixType, WorkflowStatusType } from 'src/app/utils/types';
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
import type { TierEntryModifiedByUser } from 'src/app/classes/release-tracks';

export interface ReleaseTrackObjectItem {
object_ref: string;
Expand All @@ -17,6 +19,10 @@ export interface ReleaseTrackObjectItem {
object_added_by?: string;
object_staged_at?: Date | string;
object_staged_by?: string;
attack_id?: string;
name?: string;
description?: string;
modified_by_user?: TierEntryModifiedByUser;
[key: string]: any;
}

Expand All @@ -29,6 +35,7 @@ export interface ReleaseTrackObjectItem {
MatCardModule,
MatIconModule,
MatTooltipModule,
MarkdownModule,
UserAvatarComponent,
],
templateUrl: './release-track-object-card.component.html',
Expand All @@ -46,15 +53,20 @@ export class ReleaseTrackObjectCardComponent {
@Output() diffObject = new EventEmitter<ReleaseTrackObjectItem>();

public get title(): string {
return this.item?.name || this.fallbackObjectLabel;
return (
this.item?.name || this.item?.object_name || this.fallbackObjectLabel
);
}

public get subtitle(): string {
return this.item?.attackId || '<<ATT&CK ID>>';
return this.item?.attack_id || this.item?.attackId || '<<ATT&CK ID>>';
}

public get description(): string {
const description = this.item?.description || 'No description available.';
const description =
this.item?.description ||
this.item?.object_description ||
'No description available.';
return this.firstParagraph(description);
}

Expand All @@ -79,11 +91,13 @@ export class ReleaseTrackObjectCardComponent {

public get modifiedByName(): string {
return (
this.item?.modified_by_user ||
this.item?.object_modified_by ||
this.item?.object_added_by ||
this.item?.object_staged_by ||
this.item?.modified_by_ref ||
this.userDisplayName(this.item?.modified_by_user, false) ||
this.userDisplayName(
this.item?.object_modified_by ||
this.item?.object_added_by ||
this.item?.object_staged_by ||
this.item?.modified_by_ref
) ||
'Unknown User'
);
}
Expand Down Expand Up @@ -125,4 +139,16 @@ export class ReleaseTrackObjectCardComponent {
.filter(Boolean);
return paragraph || 'No description available.';
}

private userDisplayName(value: any, includeId = true): string | null {
if (!value) return null;
if (typeof value === 'string') return value;

return (
value.displayName ||
value.username ||
value.name ||
(includeId ? value.id : null)
);
}
}
Loading
Loading