@shipfox/api-integration-jira 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +11 -0
  5. package/LICENSE +21 -0
  6. package/README.md +5 -0
  7. package/dist/config.d.ts +10 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +28 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/core/errors.d.ts +10 -0
  12. package/dist/core/errors.d.ts.map +1 -0
  13. package/dist/core/errors.js +20 -0
  14. package/dist/core/errors.js.map +1 -0
  15. package/dist/core/tokens.d.ts +36 -0
  16. package/dist/core/tokens.d.ts.map +1 -0
  17. package/dist/core/tokens.js +40 -0
  18. package/dist/core/tokens.js.map +1 -0
  19. package/dist/db/db.d.ts +475 -0
  20. package/dist/db/db.d.ts.map +1 -0
  21. package/dist/db/db.js +18 -0
  22. package/dist/db/db.js.map +1 -0
  23. package/dist/db/installations.d.ts +41 -0
  24. package/dist/db/installations.d.ts.map +1 -0
  25. package/dist/db/installations.js +65 -0
  26. package/dist/db/installations.js.map +1 -0
  27. package/dist/db/migrations.d.ts +2 -0
  28. package/dist/db/migrations.d.ts.map +1 -0
  29. package/dist/db/migrations.js +5 -0
  30. package/dist/db/migrations.js.map +1 -0
  31. package/dist/db/schema/common.d.ts +2 -0
  32. package/dist/db/schema/common.d.ts.map +1 -0
  33. package/dist/db/schema/common.js +4 -0
  34. package/dist/db/schema/common.js.map +1 -0
  35. package/dist/db/schema/installations.d.ts +239 -0
  36. package/dist/db/schema/installations.d.ts.map +1 -0
  37. package/dist/db/schema/installations.js +48 -0
  38. package/dist/db/schema/installations.js.map +1 -0
  39. package/dist/index.d.ts +15 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +16 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/tsconfig.test.tsbuildinfo +1 -0
  44. package/drizzle/0000_whole_master_chief.sql +18 -0
  45. package/drizzle/meta/0000_snapshot.json +145 -0
  46. package/drizzle/meta/_journal.json +13 -0
  47. package/drizzle.config.ts +7 -0
  48. package/package.json +63 -0
  49. package/src/config.test.ts +37 -0
  50. package/src/config.ts +27 -0
  51. package/src/core/errors.ts +22 -0
  52. package/src/core/tokens.test.ts +106 -0
  53. package/src/core/tokens.ts +77 -0
  54. package/src/db/db.ts +16 -0
  55. package/src/db/installations.test.ts +93 -0
  56. package/src/db/installations.ts +125 -0
  57. package/src/db/migrations.ts +4 -0
  58. package/src/db/schema/common.ts +3 -0
  59. package/src/db/schema/installations.ts +48 -0
  60. package/src/index.test.ts +9 -0
  61. package/src/index.ts +36 -0
  62. package/test/api-secrets.d.ts +26 -0
  63. package/test/env.ts +15 -0
  64. package/test/factories/index.ts +1 -0
  65. package/test/factories/jira-installation.ts +35 -0
  66. package/test/globalSetup.ts +23 -0
  67. package/test/index.ts +1 -0
  68. package/test/setup.ts +17 -0
  69. package/tsconfig.build.json +9 -0
  70. package/tsconfig.build.tsbuildinfo +1 -0
  71. package/tsconfig.json +3 -0
  72. package/tsconfig.test.json +9 -0
  73. package/vitest.config.ts +12 -0
@@ -0,0 +1,18 @@
1
+ CREATE TABLE "integrations_jira_installations" (
2
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
3
+ "connection_id" uuid NOT NULL,
4
+ "cloud_id" text NOT NULL,
5
+ "site_url" text NOT NULL,
6
+ "site_name" text NOT NULL,
7
+ "authorizing_account_id" text NOT NULL,
8
+ "scopes" jsonb NOT NULL,
9
+ "webhook_ids" jsonb DEFAULT '[]'::jsonb NOT NULL,
10
+ "webhook_expires_at" timestamp with time zone,
11
+ "status" text NOT NULL,
12
+ "token_expires_at" timestamp with time zone,
13
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
14
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
15
+ );
16
+ --> statement-breakpoint
17
+ CREATE UNIQUE INDEX "integrations_jira_installations_connection_unique" ON "integrations_jira_installations" USING btree ("connection_id");--> statement-breakpoint
18
+ CREATE INDEX "integrations_jira_installations_cloud_id_idx" ON "integrations_jira_installations" USING btree ("cloud_id");
@@ -0,0 +1,145 @@
1
+ {
2
+ "id": "5903a3ad-8572-4443-9fb4-8eb0298fab14",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.integrations_jira_installations": {
8
+ "name": "integrations_jira_installations",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "uuidv7()"
17
+ },
18
+ "connection_id": {
19
+ "name": "connection_id",
20
+ "type": "uuid",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "cloud_id": {
25
+ "name": "cloud_id",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "site_url": {
31
+ "name": "site_url",
32
+ "type": "text",
33
+ "primaryKey": false,
34
+ "notNull": true
35
+ },
36
+ "site_name": {
37
+ "name": "site_name",
38
+ "type": "text",
39
+ "primaryKey": false,
40
+ "notNull": true
41
+ },
42
+ "authorizing_account_id": {
43
+ "name": "authorizing_account_id",
44
+ "type": "text",
45
+ "primaryKey": false,
46
+ "notNull": true
47
+ },
48
+ "scopes": {
49
+ "name": "scopes",
50
+ "type": "jsonb",
51
+ "primaryKey": false,
52
+ "notNull": true
53
+ },
54
+ "webhook_ids": {
55
+ "name": "webhook_ids",
56
+ "type": "jsonb",
57
+ "primaryKey": false,
58
+ "notNull": true,
59
+ "default": "'[]'::jsonb"
60
+ },
61
+ "webhook_expires_at": {
62
+ "name": "webhook_expires_at",
63
+ "type": "timestamp with time zone",
64
+ "primaryKey": false,
65
+ "notNull": false
66
+ },
67
+ "status": {
68
+ "name": "status",
69
+ "type": "text",
70
+ "primaryKey": false,
71
+ "notNull": true
72
+ },
73
+ "token_expires_at": {
74
+ "name": "token_expires_at",
75
+ "type": "timestamp with time zone",
76
+ "primaryKey": false,
77
+ "notNull": false
78
+ },
79
+ "created_at": {
80
+ "name": "created_at",
81
+ "type": "timestamp with time zone",
82
+ "primaryKey": false,
83
+ "notNull": true,
84
+ "default": "now()"
85
+ },
86
+ "updated_at": {
87
+ "name": "updated_at",
88
+ "type": "timestamp with time zone",
89
+ "primaryKey": false,
90
+ "notNull": true,
91
+ "default": "now()"
92
+ }
93
+ },
94
+ "indexes": {
95
+ "integrations_jira_installations_connection_unique": {
96
+ "name": "integrations_jira_installations_connection_unique",
97
+ "columns": [
98
+ {
99
+ "expression": "connection_id",
100
+ "isExpression": false,
101
+ "asc": true,
102
+ "nulls": "last"
103
+ }
104
+ ],
105
+ "isUnique": true,
106
+ "concurrently": false,
107
+ "method": "btree",
108
+ "with": {}
109
+ },
110
+ "integrations_jira_installations_cloud_id_idx": {
111
+ "name": "integrations_jira_installations_cloud_id_idx",
112
+ "columns": [
113
+ {
114
+ "expression": "cloud_id",
115
+ "isExpression": false,
116
+ "asc": true,
117
+ "nulls": "last"
118
+ }
119
+ ],
120
+ "isUnique": false,
121
+ "concurrently": false,
122
+ "method": "btree",
123
+ "with": {}
124
+ }
125
+ },
126
+ "foreignKeys": {},
127
+ "compositePrimaryKeys": {},
128
+ "uniqueConstraints": {},
129
+ "policies": {},
130
+ "checkConstraints": {},
131
+ "isRLSEnabled": false
132
+ }
133
+ },
134
+ "enums": {},
135
+ "schemas": {},
136
+ "sequences": {},
137
+ "roles": {},
138
+ "policies": {},
139
+ "views": {},
140
+ "_meta": {
141
+ "columns": {},
142
+ "schemas": {},
143
+ "tables": {}
144
+ }
145
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1784385935754,
9
+ "tag": "0000_whole_master_chief",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import {defineConfig} from 'drizzle-kit';
2
+
3
+ export default defineConfig({
4
+ schema: './src/db/schema/*.ts',
5
+ out: './drizzle',
6
+ dialect: 'postgresql',
7
+ });
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@shipfox/api-integration-jira",
3
+ "license": "MIT",
4
+ "version": "4.0.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/api/integration/jira"
9
+ },
10
+ "private": false,
11
+ "type": "module",
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "imports": {
15
+ "#test/*": "./test/*",
16
+ "#*": {
17
+ "workspace-source": "./src/*",
18
+ "development": "./src/*",
19
+ "default": "./dist/*"
20
+ }
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "development": {
25
+ "types": "./src/index.ts",
26
+ "default": "./src/index.ts"
27
+ },
28
+ "default": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "drizzle-orm": "^0.45.2",
36
+ "@shipfox/api-integration-jira-dto": "4.0.0",
37
+ "@shipfox/node-drizzle": "0.3.0",
38
+ "@shipfox/config": "1.2.1",
39
+ "@shipfox/node-postgres": "0.4.1"
40
+ },
41
+ "peerDependencies": {
42
+ "@shipfox/api-secrets": "4.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@types/pg": "^8.15.5",
46
+ "drizzle-kit": "^0.31.10",
47
+ "fishery": "^2.4.0",
48
+ "@shipfox/swc": "1.2.5",
49
+ "@shipfox/biome": "1.8.1",
50
+ "@shipfox/typescript": "1.1.6",
51
+ "@shipfox/vitest": "1.2.2",
52
+ "@shipfox/ts-config": "1.3.8"
53
+ },
54
+ "scripts": {
55
+ "build": "shipfox-swc",
56
+ "check": "shipfox-biome-check",
57
+ "check:fix": "shipfox-biome-check --write",
58
+ "test": "shipfox-vitest-run",
59
+ "test:watch": "shipfox-vitest-watch",
60
+ "type": "shipfox-tsc-check",
61
+ "type:emit": "shipfox-tsc-emit"
62
+ }
63
+ }
@@ -0,0 +1,37 @@
1
+ const jiraEnvNames = [
2
+ 'JIRA_OAUTH_CLIENT_ID',
3
+ 'JIRA_OAUTH_CLIENT_SECRET',
4
+ 'JIRA_OAUTH_REDIRECT_URL',
5
+ 'JIRA_WEBHOOK_SIGNING_SECRET',
6
+ 'JIRA_WEBHOOK_BASE_URL',
7
+ ] as const;
8
+
9
+ describe('jira config', () => {
10
+ afterEach(() => {
11
+ vi.unstubAllEnvs();
12
+ vi.resetModules();
13
+ });
14
+
15
+ it('exports validated Jira config with the default API and auth URLs', async () => {
16
+ vi.stubEnv('JIRA_API_BASE_URL', undefined);
17
+ vi.stubEnv('JIRA_AUTH_BASE_URL', undefined);
18
+ vi.resetModules();
19
+
20
+ const {config} = await import('#index.js');
21
+
22
+ for (const name of jiraEnvNames) expect(config[name]).toBe(process.env[name]);
23
+ expect(config.JIRA_API_BASE_URL).toBe('https://api.atlassian.com');
24
+ expect(config.JIRA_AUTH_BASE_URL).toBe('https://auth.atlassian.com');
25
+ });
26
+
27
+ it('accepts compatible Jira API and auth URL overrides', async () => {
28
+ vi.stubEnv('JIRA_API_BASE_URL', 'https://jira-api.example.test');
29
+ vi.stubEnv('JIRA_AUTH_BASE_URL', 'https://jira-auth.example.test');
30
+ vi.resetModules();
31
+
32
+ const {config} = await import('#index.js');
33
+
34
+ expect(config.JIRA_API_BASE_URL).toBe('https://jira-api.example.test');
35
+ expect(config.JIRA_AUTH_BASE_URL).toBe('https://jira-auth.example.test');
36
+ });
37
+ });
package/src/config.ts ADDED
@@ -0,0 +1,27 @@
1
+ import {createConfig, str, url} from '@shipfox/config';
2
+
3
+ export const config = createConfig({
4
+ JIRA_OAUTH_CLIENT_ID: str({
5
+ desc: 'OAuth client ID of the Jira app, used to start and complete the Jira connect flow. Required.',
6
+ }),
7
+ JIRA_OAUTH_CLIENT_SECRET: str({
8
+ desc: 'OAuth client secret of the Jira app, used to exchange authorization codes for access and refresh tokens. Required.',
9
+ }),
10
+ JIRA_OAUTH_REDIRECT_URL: url({
11
+ desc: 'Public client callback URL Jira redirects to after OAuth authorization, such as https://shipfox.example.com/integrations/jira/callback. Required.',
12
+ }),
13
+ JIRA_WEBHOOK_SIGNING_SECRET: str({
14
+ desc: 'Secret used to verify incoming Jira webhooks. It must match the webhook signing secret configured for the Jira app. Required.',
15
+ }),
16
+ JIRA_WEBHOOK_BASE_URL: url({
17
+ desc: 'Public base URL Jira sends webhooks to. Its origin must be added to the Jira app webhook allowlist. Required.',
18
+ }),
19
+ JIRA_API_BASE_URL: url({
20
+ desc: 'Jira API base URL. Override this only when routing Jira API requests through a proxy or test server.',
21
+ default: 'https://api.atlassian.com',
22
+ }),
23
+ JIRA_AUTH_BASE_URL: url({
24
+ desc: 'Jira OAuth base URL. Override this only when routing OAuth requests through a proxy or test server.',
25
+ default: 'https://auth.atlassian.com',
26
+ }),
27
+ });
@@ -0,0 +1,22 @@
1
+ export class JiraConnectionNotFoundError extends Error {
2
+ constructor(connectionId: string) {
3
+ super(`Jira integration connection was not found: ${connectionId}`);
4
+ this.name = 'JiraConnectionNotFoundError';
5
+ }
6
+ }
7
+
8
+ export class JiraAccessTokenMissingError extends Error {
9
+ constructor(connectionId: string) {
10
+ super(`Jira access token is missing for connection: ${connectionId}`);
11
+ this.name = 'JiraAccessTokenMissingError';
12
+ }
13
+ }
14
+
15
+ export class JiraInstallationSiteMismatchError extends Error {
16
+ constructor(connectionId: string, cloudId: string) {
17
+ super(
18
+ `Jira connection is already linked to a different site: ${connectionId} (attempted ${cloudId})`,
19
+ );
20
+ this.name = 'JiraInstallationSiteMismatchError';
21
+ }
22
+ }
@@ -0,0 +1,106 @@
1
+ import {JiraAccessTokenMissingError, JiraConnectionNotFoundError} from '#core/errors.js';
2
+ import {
3
+ createJiraTokenStore,
4
+ type JiraConnectionResolverResult,
5
+ type JiraSecretsStore,
6
+ jiraSecretsNamespace,
7
+ } from './tokens.js';
8
+
9
+ let secrets: JiraSecretsStore;
10
+
11
+ beforeAll(async () => {
12
+ secrets = await import('@shipfox/api-secrets');
13
+ });
14
+
15
+ function createConnectionContext() {
16
+ const workspaceId = crypto.randomUUID();
17
+ const connectionId = crypto.randomUUID();
18
+ const resolveConnection = vi
19
+ .fn<(connectionId: string) => Promise<JiraConnectionResolverResult | undefined>>()
20
+ .mockResolvedValue({workspaceId});
21
+ const store = createJiraTokenStore({resolveConnection, secrets});
22
+
23
+ return {workspaceId, connectionId, resolveConnection, store};
24
+ }
25
+
26
+ function storedToken(input: {
27
+ workspaceId: string;
28
+ connectionId: string;
29
+ key: 'ACCESS_TOKEN' | 'REFRESH_TOKEN';
30
+ }) {
31
+ return secrets.getSecret({
32
+ workspaceId: input.workspaceId,
33
+ namespace: jiraSecretsNamespace(input.connectionId),
34
+ key: input.key,
35
+ });
36
+ }
37
+
38
+ describe('createJiraTokenStore.storeTokens', () => {
39
+ it('stores access and refresh tokens in the Jira system namespace', async () => {
40
+ const {workspaceId, connectionId, store} = createConnectionContext();
41
+
42
+ await store.storeTokens({
43
+ connectionId,
44
+ accessToken: 'access-token',
45
+ refreshToken: 'refresh-token',
46
+ editedBy: crypto.randomUUID(),
47
+ });
48
+
49
+ await expect(storedToken({workspaceId, connectionId, key: 'ACCESS_TOKEN'})).resolves.toBe(
50
+ 'access-token',
51
+ );
52
+ await expect(storedToken({workspaceId, connectionId, key: 'REFRESH_TOKEN'})).resolves.toBe(
53
+ 'refresh-token',
54
+ );
55
+ });
56
+
57
+ it('stores only the access token when no refresh token was issued', async () => {
58
+ const {workspaceId, connectionId, store} = createConnectionContext();
59
+
60
+ await store.storeTokens({connectionId, accessToken: 'access-token'});
61
+
62
+ await expect(storedToken({workspaceId, connectionId, key: 'ACCESS_TOKEN'})).resolves.toBe(
63
+ 'access-token',
64
+ );
65
+ await expect(
66
+ storedToken({workspaceId, connectionId, key: 'REFRESH_TOKEN'}),
67
+ ).resolves.toBeNull();
68
+ });
69
+
70
+ it('throws a typed error when the connection cannot be resolved', async () => {
71
+ const {connectionId, resolveConnection, store} = createConnectionContext();
72
+ resolveConnection.mockResolvedValue(undefined);
73
+
74
+ const result = store.storeTokens({connectionId, accessToken: 'access-token'});
75
+
76
+ await expect(result).rejects.toBeInstanceOf(JiraConnectionNotFoundError);
77
+ });
78
+ });
79
+
80
+ describe('createJiraTokenStore.getAccessToken', () => {
81
+ it('returns the stored access token', async () => {
82
+ const {connectionId, store} = createConnectionContext();
83
+ await store.storeTokens({connectionId, accessToken: 'access-token'});
84
+
85
+ const result = await store.getAccessToken({connectionId});
86
+
87
+ expect(result).toBe('access-token');
88
+ });
89
+
90
+ it('throws a typed error when no access token is stored', async () => {
91
+ const {connectionId, store} = createConnectionContext();
92
+
93
+ const result = store.getAccessToken({connectionId});
94
+
95
+ await expect(result).rejects.toBeInstanceOf(JiraAccessTokenMissingError);
96
+ });
97
+
98
+ it('throws a typed error when the connection cannot be resolved', async () => {
99
+ const {connectionId, resolveConnection, store} = createConnectionContext();
100
+ resolveConnection.mockResolvedValue(undefined);
101
+
102
+ const result = store.getAccessToken({connectionId});
103
+
104
+ await expect(result).rejects.toBeInstanceOf(JiraConnectionNotFoundError);
105
+ });
106
+ });
@@ -0,0 +1,77 @@
1
+ import {JiraAccessTokenMissingError, JiraConnectionNotFoundError} from '#core/errors.js';
2
+
3
+ const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
4
+ const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
5
+
6
+ export interface JiraConnectionResolverResult {
7
+ workspaceId: string;
8
+ }
9
+
10
+ export interface JiraSecretsStore {
11
+ getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;
12
+ setSecrets(params: {
13
+ workspaceId: string;
14
+ namespace: string;
15
+ values: Record<string, string>;
16
+ editedBy?: string | null | undefined;
17
+ }): Promise<void>;
18
+ }
19
+
20
+ export interface CreateJiraTokenStoreParams {
21
+ resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;
22
+ secrets: JiraSecretsStore;
23
+ }
24
+
25
+ export interface StoreJiraTokensParams {
26
+ connectionId: string;
27
+ accessToken: string;
28
+ refreshToken?: string | undefined;
29
+ editedBy?: string | null | undefined;
30
+ }
31
+
32
+ export interface GetJiraAccessTokenParams {
33
+ connectionId: string;
34
+ }
35
+
36
+ export interface JiraTokenStore {
37
+ storeTokens(params: StoreJiraTokensParams): Promise<void>;
38
+ getAccessToken(params: GetJiraAccessTokenParams): Promise<string>;
39
+ }
40
+
41
+ export function jiraSecretsNamespace(connectionId: string): string {
42
+ return `system/integrations/jira/${connectionId}`;
43
+ }
44
+
45
+ export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {
46
+ async function resolveWorkspaceId(connectionId: string): Promise<string> {
47
+ const connection = await params.resolveConnection(connectionId);
48
+ if (!connection) throw new JiraConnectionNotFoundError(connectionId);
49
+ return connection.workspaceId;
50
+ }
51
+
52
+ return {
53
+ async storeTokens(input) {
54
+ const workspaceId = await resolveWorkspaceId(input.connectionId);
55
+ const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};
56
+ if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;
57
+
58
+ await params.secrets.setSecrets({
59
+ workspaceId,
60
+ namespace: jiraSecretsNamespace(input.connectionId),
61
+ values,
62
+ editedBy: input.editedBy,
63
+ });
64
+ },
65
+
66
+ async getAccessToken(input) {
67
+ const workspaceId = await resolveWorkspaceId(input.connectionId);
68
+ const token = await params.secrets.getSecret({
69
+ workspaceId,
70
+ namespace: jiraSecretsNamespace(input.connectionId),
71
+ key: ACCESS_TOKEN_KEY,
72
+ });
73
+ if (!token) throw new JiraAccessTokenMissingError(input.connectionId);
74
+ return token;
75
+ },
76
+ };
77
+ }
package/src/db/db.ts ADDED
@@ -0,0 +1,16 @@
1
+ import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
2
+ import {pgClient} from '@shipfox/node-postgres';
3
+ import {jiraInstallations} from './schema/installations.js';
4
+
5
+ export const schema = {jiraInstallations};
6
+
7
+ let database: NodePgDatabase<typeof schema> | undefined;
8
+
9
+ export function db() {
10
+ if (!database) database = drizzle(pgClient(), {schema});
11
+ return database;
12
+ }
13
+
14
+ export function closeDb(): void {
15
+ database = undefined;
16
+ }
@@ -0,0 +1,93 @@
1
+ import {JiraInstallationSiteMismatchError} from '#core/errors.js';
2
+ import {
3
+ getJiraInstallationByConnectionId,
4
+ getJiraInstallationByWebhookId,
5
+ markJiraInstallationRevoked,
6
+ upsertJiraInstallation,
7
+ } from './installations.js';
8
+
9
+ function createInstallationInput(
10
+ overrides: Partial<Parameters<typeof upsertJiraInstallation>[0]> = {},
11
+ ) {
12
+ return {
13
+ connectionId: crypto.randomUUID(),
14
+ cloudId: crypto.randomUUID(),
15
+ siteUrl: 'https://acme.atlassian.net',
16
+ siteName: 'Acme',
17
+ authorizingAccountId: crypto.randomUUID(),
18
+ scopes: ['read:jira-work'],
19
+ webhookIds: [Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)],
20
+ status: 'installed' as const,
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe('jira installations', () => {
26
+ it('inserts an installation and reads it by connection and webhook id', async () => {
27
+ const webhookId = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
28
+ const input = createInstallationInput({webhookIds: [webhookId]});
29
+
30
+ const installation = await upsertJiraInstallation(input);
31
+ const byConnection = await getJiraInstallationByConnectionId(input.connectionId);
32
+ const byWebhook = await getJiraInstallationByWebhookId(webhookId);
33
+
34
+ expect(byConnection).toEqual(installation);
35
+ expect(byWebhook).toEqual(installation);
36
+ });
37
+
38
+ it('updates mutable metadata for the same connection and Jira site', async () => {
39
+ const input = createInstallationInput();
40
+ await upsertJiraInstallation(input);
41
+
42
+ const result = await upsertJiraInstallation({
43
+ ...input,
44
+ siteUrl: 'https://renamed.atlassian.net',
45
+ siteName: 'Acme renamed',
46
+ authorizingAccountId: crypto.randomUUID(),
47
+ scopes: ['read:jira-work', 'write:jira-work'],
48
+ webhookIds: [123],
49
+ });
50
+
51
+ expect(result).toMatchObject({
52
+ siteUrl: 'https://renamed.atlassian.net',
53
+ siteName: 'Acme renamed',
54
+ scopes: ['read:jira-work', 'write:jira-work'],
55
+ webhookIds: [123],
56
+ });
57
+ });
58
+
59
+ it('refuses to repoint a connection to a different Jira site', async () => {
60
+ const first = createInstallationInput();
61
+ await upsertJiraInstallation(first);
62
+ const second = createInstallationInput({connectionId: first.connectionId});
63
+
64
+ const result = upsertJiraInstallation(second);
65
+
66
+ await expect(result).rejects.toBeInstanceOf(JiraInstallationSiteMismatchError);
67
+ await expect(getJiraInstallationByConnectionId(first.connectionId)).resolves.toMatchObject({
68
+ cloudId: first.cloudId,
69
+ });
70
+ });
71
+
72
+ it('returns undefined for unknown connection and webhook ids', async () => {
73
+ const connectionId = crypto.randomUUID();
74
+ const webhookId = Number.MAX_SAFE_INTEGER;
75
+
76
+ const byConnection = await getJiraInstallationByConnectionId(connectionId);
77
+ const byWebhook = await getJiraInstallationByWebhookId(webhookId);
78
+
79
+ expect(byConnection).toBeUndefined();
80
+ expect(byWebhook).toBeUndefined();
81
+ });
82
+
83
+ it('marks an installation revoked and returns undefined for an unknown connection', async () => {
84
+ const input = createInstallationInput();
85
+ await upsertJiraInstallation(input);
86
+
87
+ const revoked = await markJiraInstallationRevoked(input.connectionId);
88
+ const missing = await markJiraInstallationRevoked(crypto.randomUUID());
89
+
90
+ expect(revoked?.status).toBe('revoked');
91
+ expect(missing).toBeUndefined();
92
+ });
93
+ });