• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

vzakharchenko / Forge-Secure-Notes-for-Jira / 20675434311

03 Jan 2026 09:29AM UTC coverage: 85.91% (+0.02%) from 85.893%
20675434311

push

github

vzakharchenko
sonar fix

155 of 193 branches covered (80.31%)

Branch coverage included in aggregate %.

17 of 18 new or added lines in 11 files covered. (94.44%)

1 existing line in 1 file now uncovered.

534 of 609 relevant lines covered (87.68%)

20.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

48.63
/src/services/SecurityNoteService.ts
1
import { SecurityNoteStatus, SHARED_EVENT_NAME } from "../../shared/Types";
2
import { getAppContext, withAppContext } from "../controllers";
3
import { NewSecurityNote } from "../../shared/dto";
4
import { InferInsertModel, InferSelectModel } from "drizzle-orm";
5
import {
6
  calculateHash,
7
  sendExpirationNotification,
8
  sendIssueNotification,
9
  sendNoteDeletedNotification,
10
  isIssueContext,
11
  IssueContext,
12
} from "../core";
13
import { v4 } from "uuid";
14
import {
15
  SecurityNoteData,
16
  ProjectInfo,
17
  ProjectIssue,
18
  OpenSecurityNote,
19
  UserViewInfoType,
20
  ViewMySecurityNotes,
21
} from "../../shared/responses";
22
import { publishGlobal } from "@forge/realtime";
23
import { inject, injectable } from "inversify";
24
import { FORGE_INJECTION_TOKENS } from "../constants";
25
import { JiraUserService } from "../jira";
26
import { SecurityNoteRepository, securityNotes } from "../database";
27
import { BootstrapService } from "./BootstrapService";
28
import { SecurityStorage } from "../storage";
29

30
@injectable()
31
export class SecurityNoteService {
29✔
32
  constructor(
33
    @inject(FORGE_INJECTION_TOKENS.JiraUserService)
34
    private readonly jiraUserService: JiraUserService,
175✔
35
    @inject(FORGE_INJECTION_TOKENS.SecurityNoteRepository)
36
    private readonly securityNoteRepository: SecurityNoteRepository,
175✔
37
    @inject(FORGE_INJECTION_TOKENS.BootstrapService)
38
    private readonly bootstrapService: BootstrapService,
175✔
39
    @inject(FORGE_INJECTION_TOKENS.SecurityStorage)
40
    private readonly securityStorage: SecurityStorage,
175✔
41
  ) {}
42

43
  private mapSecurityNotesToView(
44
    securityDbNotes: (InferSelectModel<typeof securityNotes> & {
45
      count: number;
46
    })[],
47
    defaults?: {
48
      issueId?: string;
49
      issueKey?: string;
50
      projectId?: string;
51
      projectKey?: string;
52
    },
53
  ): ViewMySecurityNotes[] {
54
    if (!securityDbNotes || securityDbNotes.length === 0) {
4!
55
      return [];
×
56
    }
57
    return securityDbNotes.map((sn) => ({
4✔
58
      id: sn.id,
59
      createdBy: {
60
        displayName: sn.createdUserName,
61
        accountId: sn.createdBy,
62
        avatarUrl: sn.createdAvatarUrl,
63
      },
64
      targetUser: {
65
        displayName: sn.targetUserName,
66
        accountId: sn.targetUserId,
67
        avatarUrl: sn.targetAvatarUrl,
68
      },
69
      viewTimeOut: "5mins",
70
      status: sn.status as SecurityNoteStatus,
71
      expiration: sn.expiryDate,
72
      issueId: sn.issueId ?? defaults?.issueId ?? undefined,
12✔
73
      issueKey: sn.issueKey ?? defaults?.issueKey ?? undefined,
8✔
74
      projectId: sn.projectId ?? defaults?.projectId ?? undefined,
12✔
75
      projectKey: sn.projectKey ?? defaults?.projectKey ?? undefined,
10✔
76
      createdAt: sn.createdAt,
77
      viewedAt: sn.viewedAt ?? undefined,
8✔
78
      deletedAt: sn.deletedAt ?? undefined,
8✔
79
      expiry: sn.expiry,
80
      description: sn.description ?? undefined,
8✔
81
      count: sn.count,
82
    }));
83
  }
84
  @withAppContext()
85
  async getSecurityNoteByIssue(
29✔
86
    issueIdOrKey: string,
87
    limit: number,
88
    offset: number,
89
  ): Promise<ViewMySecurityNotes[]> {
90
    const context = getAppContext()!;
2✔
91
    let isAdmin = await this.bootstrapService.isAdmin();
2✔
92
    const securityDbNotes = await this.securityNoteRepository.getAllSecurityNotesByIssue(
2✔
93
      issueIdOrKey,
94
      limit,
95
      offset,
96
      isAdmin ? null : context.accountId,
2✔
97
    );
98
    return this.mapSecurityNotesToView(securityDbNotes);
2✔
99
  }
100
  @withAppContext()
101
  async getSecurityNoteByProject(
29✔
102
    projectIdOrKey: string,
103
    limit: number,
104
    offset: number,
105
  ): Promise<ViewMySecurityNotes[]> {
106
    const context = getAppContext()!;
1✔
107
    let isAdmin = await this.bootstrapService.isAdmin();
1✔
108
    const securityDbNotes = await this.securityNoteRepository.getAllSecurityNotesByProject(
1✔
109
      projectIdOrKey,
110
      limit,
111
      offset,
112
      isAdmin ? null : context.accountId,
1!
113
    );
114
    return this.mapSecurityNotesToView(securityDbNotes);
1✔
115
  }
116

117
  async getIssuesAndProjects(): Promise<ProjectIssue> {
118
    return {
1✔
119
      result: (await this.securityNoteRepository.getIssuesAndProjects()) as ProjectInfo[],
120
    };
121
  }
122
  @withAppContext()
123
  async getSecurityNoteByAccountId(
29✔
124
    accountId: string,
125
    limit: number,
126
    offset: number,
127
  ): Promise<ViewMySecurityNotes[]> {
128
    const context = getAppContext()!;
2✔
129
    if (!(await this.bootstrapService.isAdmin()) && context.accountId !== accountId) {
2✔
130
      return [];
1✔
131
    }
132
    const securityDbNotes = await this.securityNoteRepository.getAllSecurityNotesByAccountId(
1✔
133
      accountId,
134
      limit,
135
      offset,
136
    );
137
    return this.mapSecurityNotesToView(securityDbNotes);
1✔
138
  }
139

140
  async getSecurityNoteUsers(): Promise<UserViewInfoType[]> {
141
    return this.securityNoteRepository.getSecurityNoteUsers();
1✔
142
  }
143

144
  async expireSecurityNotes(): Promise<void> {
145
    const notes = await this.securityNoteRepository.getAllExpiredNotes();
6✔
146
    if (notes?.length) {
6✔
147
      for (const note of notes) {
1✔
148
        await this.securityStorage.deletePayload(note.id);
1✔
149
        try {
1✔
150
          if (note.issueKey) {
1!
151
            await sendExpirationNotification({
1✔
152
              issueKey: note.issueKey,
153
              recipientAccountId: note.targetUserId,
154
              displayName: note.createdUserName,
155
            });
156
          }
157
        } catch (e) {
158
          // eslint-disable-next-line no-console
159
          console.error(e);
×
160
        }
161
      }
162
      await this.securityNoteRepository.expireSecurityNote(notes.map((n) => n.id));
1✔
163
    }
164
  }
165

166
  @withAppContext()
167
  async getSecuredData(securityNoteId: string, key: string): Promise<SecurityNoteData | undefined> {
29✔
168
    const sn = await this.securityNoteRepository.getSecurityNode(securityNoteId);
×
169
    if (!sn) {
×
170
      return undefined;
×
171
    }
172
    if (sn.encryptionKeyHash !== (await calculateHash(key, sn.targetUserId))) {
×
173
      throw new Error(
×
174
        `SecurityKey is not valid, please ask ${sn.createdUserName} to sent you it. `,
175
      );
176
    }
177
    const encryptedData = await this.securityStorage.getPayload(securityNoteId);
×
178
    if (!encryptedData) {
×
179
      // eslint-disable-next-line no-console
180
      console.error("data does not exists");
×
181
      await this.securityNoteRepository.deleteSecurityNote(securityNoteId);
×
182
      return undefined;
×
183
    }
184
    await this.securityStorage.deletePayload(securityNoteId);
×
185
    await this.securityNoteRepository.viewSecurityNote(securityNoteId);
×
186
    await publishGlobal(SHARED_EVENT_NAME, sn.issueId ?? "");
×
187
    return {
×
188
      id: sn.id,
189
      iv: sn.iv,
190
      salt: sn.salt,
191
      encryptedData: encryptedData,
192
      viewTimeOut: 300,
193
      expiry: sn.expiry,
194
    };
195
  }
196

197
  @withAppContext()
198
  async isValidLink(securityNoteId: string): Promise<OpenSecurityNote> {
29✔
199
    const accountId = getAppContext()?.accountId;
×
200
    if (!accountId) return { valid: false };
×
201

202
    const sn = await this.securityNoteRepository.getSecurityNode(securityNoteId);
×
203
    if (!sn) return { valid: false };
×
204

205
    const isValid = sn.targetUserId === accountId;
×
206

207
    return {
×
208
      valid: isValid,
209
      sourceAccountId: isValid
×
210
        ? await calculateHash(sn.description ?? sn.createdBy, sn.createdBy)
×
211
        : undefined,
212
    };
213
  }
214

215
  addHours(date: Date, hours: number): Date {
216
    const result = new Date(date);
5✔
217
    result.setHours(result.getHours() + hours);
5✔
218
    return result;
5✔
219
  }
220

221
  getExpire(expire: string): Date {
222
    switch (expire) {
4✔
223
      case "1h": {
224
        return this.addHours(new Date(), 1);
1✔
225
      }
226
      case "1d": {
227
        return this.addHours(new Date(), 24);
1✔
228
      }
229
      case "7d": {
230
        return this.addHours(new Date(), 24 * 7);
1✔
231
      }
232
      default: {
233
        return this.addHours(new Date(), 24 * 10);
1✔
234
      }
235
    }
236
  }
237

238
  @withAppContext()
239
  async getMySecurityNoteIssue(): Promise<ViewMySecurityNotes[]> {
29✔
240
    const { accountId, context } = getAppContext()!;
×
241
    if (!isIssueContext(context)) {
×
242
      throw new Error("expected Issue context");
×
243
    }
244

NEW
245
    const issueContext = context;
×
246
    const { key, id } = issueContext.extension.issue;
×
247
    const securityDbNotes = await this.securityNoteRepository.getAllMySecurityNotes(key, accountId);
×
248
    return this.mapSecurityNotesToView(securityDbNotes, {
×
249
      issueId: id,
250
      issueKey: key,
251
      projectId: issueContext.extension.project.id,
252
      projectKey: issueContext.extension.project.key,
253
    });
254
  }
255

256
  @withAppContext()
257
  async createSecurityNote(securityNote: NewSecurityNote): Promise<void> {
29✔
258
    const appContext = getAppContext()!;
×
259
    const accountId = appContext.accountId;
×
260
    const context = appContext.context as IssueContext;
×
261
    const datas: Partial<InferInsertModel<typeof securityNotes>>[] = [];
×
262
    const currentUser = await this.jiraUserService.getCurrentUser();
×
263
    for (const targetUser of securityNote.targetUsers) {
×
264
      const data: Partial<InferInsertModel<typeof securityNotes>> = {
×
265
        issueKey: context.extension.issue.key,
266
        issueId: context.extension.issue.id,
267
        projectId: context.extension.project.id,
268
        projectKey: context.extension.project.key,
269
        targetUserId: targetUser.accountId,
270
        targetUserName: targetUser.userName,
271
        encryptionKeyHash: await calculateHash(
272
          securityNote.encryptionKeyHash,
273
          targetUser.accountId,
274
        ),
275
        iv: securityNote.iv,
276
        salt: securityNote.salt,
277
        isCustomExpiry: securityNote.isCustomExpiry ? 1 : 0,
×
278
        expiry: securityNote.expiry,
279
        description: securityNote.description,
280
      };
281
      data.createdBy = accountId;
×
282
      if (currentUser) {
×
283
        data.createdUserName = currentUser.displayName;
×
284
        data.createdAvatarUrl = currentUser.avatarUrls["32x32"];
×
285
      } else {
286
        data.createdUserName = accountId;
×
287
        data.createdAvatarUrl = "";
×
288
      }
289
      const targetUserInfo = await this.jiraUserService.getUserById(String(data.targetUserId));
×
290
      if (targetUserInfo) {
×
291
        data.targetUserName = targetUserInfo.displayName;
×
292
        data.targetAvatarUrl = targetUserInfo.avatarUrls["32x32"];
×
293
      } else {
294
        data.targetAvatarUrl = "";
×
295
      }
296
      data.createdAt = new Date();
×
297
      data.expiryDate =
×
298
        Number(data.isCustomExpiry) > 0
×
299
          ? new Date(String(data.expiry))
300
          : this.getExpire(String(data.expiry));
301
      data.status = "NEW";
×
302
      data.id = v4();
×
303
      datas.push(data);
×
304
    }
305
    await this.securityNoteRepository.createSecurityNote(
×
306
      datas as Partial<InferInsertModel<typeof securityNotes>>[],
307
    );
308
    for (const data of datas) {
×
309
      await this.securityStorage.savePayload(String(data.id), securityNote.encryptedPayload);
×
310
      const appUrlParts = context.localId.split("/");
×
311
      const appUrl = `${appUrlParts[1]}/${appUrlParts[2]}/view/${data.id}`;
×
312
      const noteLink = `${context.siteUrl}/jira/apps/${appUrl}`;
×
313
      try {
×
314
        await sendIssueNotification({
×
315
          issueKey: context.extension.issue.key,
316
          recipientAccountId: String(data.targetUserId),
317
          displayName: currentUser?.displayName ?? accountId,
×
318
          noteLink: noteLink,
319
          expiryDate: data.expiryDate as Date,
320
        });
321
      } catch (e) {
322
        // eslint-disable-next-line no-console
323
        console.error(e);
×
324
      }
325
    }
326
  }
327

328
  async deleteSecurityNote(securityNoteId: string): Promise<void> {
329
    await this.securityStorage.deletePayload(securityNoteId);
2✔
330
    const sn = await this.securityNoteRepository.getSecurityNode(securityNoteId);
2✔
331
    if (sn) {
2✔
332
      await this.securityNoteRepository.deleteSecurityNote(securityNoteId);
1✔
333
      try {
1✔
334
        await sendNoteDeletedNotification({
1✔
335
          issueKey: sn!.issueKey ?? "",
1!
336
          recipientAccountId: sn.targetUserId,
337
          displayName: sn.createdUserName,
338
        });
339
      } catch (e) {
340
        // eslint-disable-next-line no-console
341
        console.error(e);
×
342
      }
343
    }
344
  }
345
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc