acinguiux-dnr-utils 0.0.1

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 (39) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +43 -0
  3. package/package.json +57 -0
  4. package/src/application-insights/index.ts +37 -0
  5. package/src/authentication/auth.ts +59 -0
  6. package/src/authentication/callbacks.test.ts +226 -0
  7. package/src/authentication/callbacks.ts +68 -0
  8. package/src/authentication/client-auth-guard.test.ts +116 -0
  9. package/src/authentication/client-auth-guard.ts +29 -0
  10. package/src/authentication/helpers.test.ts +41 -0
  11. package/src/authentication/helpers.ts +14 -0
  12. package/src/authentication/index.ts +8 -0
  13. package/src/authentication/middleware.ts +17 -0
  14. package/src/authentication/provider.test.ts +168 -0
  15. package/src/authentication/provider.ts +56 -0
  16. package/src/authentication/services/permissions.test.ts +102 -0
  17. package/src/authentication/services/permissions.ts +52 -0
  18. package/src/formatters/index.ts +1 -0
  19. package/src/formatters/number.test.ts +49 -0
  20. package/src/formatters/number.ts +25 -0
  21. package/src/instrumentation/browser.test.ts +118 -0
  22. package/src/instrumentation/browser.ts +24 -0
  23. package/src/instrumentation/node.test.ts +63 -0
  24. package/src/instrumentation/node.ts +81 -0
  25. package/src/loggers/auth-fetch.test.ts +383 -0
  26. package/src/loggers/auth-fetch.ts +79 -0
  27. package/src/loggers/index.ts +1 -0
  28. package/src/mappers/index.ts +1 -0
  29. package/src/mappers/name.test.ts +45 -0
  30. package/src/mappers/name.ts +16 -0
  31. package/src/ms-graph/README.md +47 -0
  32. package/src/ms-graph/get-graph-token.test.ts +116 -0
  33. package/src/ms-graph/get-graph-token.ts +41 -0
  34. package/src/ms-graph/index.ts +1 -0
  35. package/src/ms-graph/user-photo.test.ts +108 -0
  36. package/src/ms-graph/user-photo.ts +45 -0
  37. package/tsconfig.json +12 -0
  38. package/types.d.ts +26 -0
  39. package/vitest.config.js +20 -0
@@ -0,0 +1,47 @@
1
+ # ms-graph setup guide
2
+
3
+ ## 1. Add environment variables
4
+
5
+ Add the following variables to your `.env` file:
6
+
7
+ ```env
8
+ MS_GRAPH_CLIENT_ID=
9
+ MS_GRAPH_CLIENT_SECRET=
10
+ MS_GRAPH_TENANT_ID=
11
+ ```
12
+
13
+ ## 2. Create the API route
14
+
15
+ In your Next.js app, create the following file `/api/[email]/photo/route.ts` with this content:
16
+
17
+ ```ts
18
+ export { GET_USER_PHOTO as GET } from 'acinguiux-dnr-utils/ms-graph';
19
+ ```
20
+
21
+ ## 3. Use the photo in your app
22
+
23
+ You can now use `/api/${email}/photo` as the image source.
24
+
25
+ ### 3.1. SDS Avatar component
26
+
27
+ ```diff
28
+ <Avatar
29
+ label="John Doe"
30
+ abbreviation="JD"
31
+ + src="/api/john.doe@shell.com/photo"
32
+ />
33
+ ```
34
+
35
+ If the user has a photo in Azure AD, it will display the image. If no photo exists, it will fall back to showing the abbreviation (e.g. “JD”).
36
+
37
+ ### 3.2. Layout component
38
+
39
+ To display the currently logged-in user's photo in the `NavBar` when using the `Layout` component from `geneva-component`, simply add the `userPhotoSrc` prop:
40
+
41
+ ```diff
42
+ <Layout
43
+ items={navItems}
44
+ user={session?.user}
45
+ + userPhotoSrc={`/api/${session?.user?.email}/photo`}
46
+ >
47
+ ```
@@ -0,0 +1,116 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { getGraphToken } from "./get-graph-token";
4
+
5
+ // Mock fetch
6
+ const mockFetch = vi.fn();
7
+ global.fetch = mockFetch;
8
+
9
+ // Store original environment variables
10
+ const originalEnv = { ...process.env };
11
+
12
+ describe("getGraphToken", () => {
13
+ beforeEach(() => {
14
+ // Setup environment variables
15
+ process.env.MS_GRAPH_CLIENT_ID = "test-client-id";
16
+ process.env.MS_GRAPH_CLIENT_SECRET = "test-client-secret";
17
+ process.env.MS_GRAPH_TENANT_ID = "test-tenant-id";
18
+
19
+ // Clear mocks between tests
20
+ vi.clearAllMocks();
21
+ });
22
+
23
+ afterEach(() => {
24
+ // Restore original environment variables
25
+ process.env = { ...originalEnv };
26
+ });
27
+
28
+ it("should fetch a new token if cache is empty", async () => {
29
+ mockFetch.mockResolvedValueOnce({
30
+ ok: true,
31
+ json: () =>
32
+ Promise.resolve({
33
+ access_token: "new-access-token",
34
+ expires_in: 3600,
35
+ }),
36
+ });
37
+
38
+ const token = await getGraphToken();
39
+
40
+ expect(token).toBe("new-access-token");
41
+ expect(mockFetch).toHaveBeenCalledTimes(1);
42
+ expect(mockFetch).toHaveBeenCalledWith(
43
+ `https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/token`,
44
+ expect.objectContaining({
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
47
+ body: expect.any(String),
48
+ }),
49
+ );
50
+ });
51
+
52
+ it("should throw an error if fetch fails", async () => {
53
+ mockFetch.mockResolvedValueOnce({ ok: false });
54
+
55
+ await expect(getGraphToken()).rejects.toThrow("Unable to obtain Graph token");
56
+ });
57
+
58
+ it("should throw error when MS_GRAPH_TENANT_ID is not set", async () => {
59
+ delete process.env.MS_GRAPH_TENANT_ID;
60
+
61
+ await expect(getGraphToken()).rejects.toThrow(
62
+ "MS_GRAPH_TENANT_ID environment variable is not set",
63
+ );
64
+ });
65
+
66
+ it("should throw error when MS_GRAPH_CLIENT_ID is not set", async () => {
67
+ delete process.env.MS_GRAPH_CLIENT_ID;
68
+
69
+ await expect(getGraphToken()).rejects.toThrow(
70
+ "MS_GRAPH_CLIENT_ID environment variable is not set",
71
+ );
72
+ });
73
+
74
+ it("should throw error when MS_GRAPH_CLIENT_SECRET is not set", async () => {
75
+ delete process.env.MS_GRAPH_CLIENT_SECRET;
76
+
77
+ await expect(getGraphToken()).rejects.toThrow(
78
+ "MS_GRAPH_CLIENT_SECRET environment variable is not set",
79
+ );
80
+ });
81
+
82
+ it("should use correct body parameters in fetch request", async () => {
83
+ mockFetch.mockResolvedValueOnce({
84
+ ok: true,
85
+ json: () => Promise.resolve({ access_token: "token" }),
86
+ });
87
+
88
+ await getGraphToken();
89
+
90
+ const callArgs = mockFetch.mock.calls[0];
91
+ const body = callArgs[1].body as string;
92
+ const params = new URLSearchParams(body);
93
+
94
+ expect(params.get("grant_type")).toBe("client_credentials");
95
+ expect(params.get("client_id")).toBe("test-client-id");
96
+ expect(params.get("client_secret")).toBe("test-client-secret");
97
+ expect(params.get("scope")).toBe("https://graph.microsoft.com/.default");
98
+ });
99
+
100
+ it("should set cache and next revalidate options", async () => {
101
+ mockFetch.mockResolvedValueOnce({
102
+ ok: true,
103
+ json: () => Promise.resolve({ access_token: "token" }),
104
+ });
105
+
106
+ await getGraphToken();
107
+
108
+ expect(mockFetch).toHaveBeenCalledWith(
109
+ expect.any(String),
110
+ expect.objectContaining({
111
+ cache: "force-cache",
112
+ next: { revalidate: 3540 }, // 3600 - 60 seconds
113
+ }),
114
+ );
115
+ });
116
+ });
@@ -0,0 +1,41 @@
1
+ const TOKEN_EXPIRATION_TIME = 3600 - 60; // 1 hour minus 1 minute to make sure we always have a valid token
2
+
3
+ export async function getGraphToken(): Promise<string> {
4
+ if (!process.env.MS_GRAPH_TENANT_ID) {
5
+ throw new Error("MS_GRAPH_TENANT_ID environment variable is not set");
6
+ }
7
+
8
+ if (!process.env.MS_GRAPH_CLIENT_ID) {
9
+ throw new Error("MS_GRAPH_CLIENT_ID environment variable is not set");
10
+ }
11
+
12
+ if (!process.env.MS_GRAPH_CLIENT_SECRET) {
13
+ throw new Error("MS_GRAPH_CLIENT_SECRET environment variable is not set");
14
+ }
15
+
16
+ const params = new URLSearchParams();
17
+ params.append("grant_type", "client_credentials");
18
+ params.append("client_id", process.env.MS_GRAPH_CLIENT_ID);
19
+ params.append("client_secret", process.env.MS_GRAPH_CLIENT_SECRET);
20
+ params.append("scope", "https://graph.microsoft.com/.default");
21
+ const body = params.toString();
22
+
23
+ const res = await fetch(
24
+ `https://login.microsoftonline.com/${process.env.MS_GRAPH_TENANT_ID}/oauth2/v2.0/token`,
25
+ {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
28
+ body,
29
+ cache: "force-cache",
30
+ next: { revalidate: TOKEN_EXPIRATION_TIME },
31
+ },
32
+ );
33
+
34
+ if (!res.ok) {
35
+ throw new Error("Unable to obtain Graph token");
36
+ }
37
+
38
+ const json = await res.json();
39
+
40
+ return json.access_token;
41
+ }
@@ -0,0 +1 @@
1
+ export { GET as GET_USER_PHOTO } from "./user-photo";
@@ -0,0 +1,108 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { getGraphToken } from "./get-graph-token";
3
+ import { GET } from "./user-photo";
4
+
5
+ // Mock dependencies
6
+ vi.mock("./get-graph-token", () => ({
7
+ getGraphToken: vi.fn(),
8
+ }));
9
+
10
+ describe("Avatar GET function", () => {
11
+ const mockEmail = "test@example.com";
12
+ const mockToken = "mock-token";
13
+ const mockRequest = {} as Request;
14
+ const mockParams = { params: { email: mockEmail } };
15
+
16
+ let originalFetch: typeof global.fetch;
17
+ let mockFetch: any;
18
+ let consoleErrorSpy: any;
19
+
20
+ beforeEach(() => {
21
+ // Save original fetch and console.error
22
+ originalFetch = global.fetch;
23
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
24
+
25
+ // Mock fetch
26
+ mockFetch = vi.fn();
27
+ global.fetch = mockFetch;
28
+
29
+ // Setup getGraphToken mock
30
+ vi.mocked(getGraphToken).mockResolvedValue(mockToken);
31
+ });
32
+
33
+ afterEach(() => {
34
+ // Restore original functions
35
+ global.fetch = originalFetch;
36
+ consoleErrorSpy.mockRestore();
37
+ vi.clearAllMocks();
38
+ });
39
+
40
+ it("should return photo image when graph API returns successful response", async () => {
41
+ // Mock successful response with body
42
+ const mockBody = new ReadableStream();
43
+ mockFetch.mockResolvedValueOnce({
44
+ ok: true,
45
+ body: mockBody,
46
+ });
47
+
48
+ const response = await GET(mockRequest, mockParams);
49
+
50
+ // Verify fetch was called correctly
51
+ expect(mockFetch).toHaveBeenCalledWith(
52
+ `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(mockEmail)}/photo/$value`,
53
+ { headers: { Authorization: `Bearer ${mockToken}` } },
54
+ );
55
+
56
+ // Verify response
57
+ expect(response).toBeInstanceOf(Response);
58
+ expect(response.headers.get("Content-Type")).toBe("image/jpeg");
59
+ expect(response.headers.get("Cache-Control")).toBe(
60
+ "public, max-age=86400, stale-while-revalidate=43200",
61
+ );
62
+ expect(await response.body).toBe(mockBody);
63
+ });
64
+
65
+ it("should return 204 No Content when graph API returns 404", async () => {
66
+ // Mock 404 response
67
+ mockFetch.mockResolvedValueOnce({
68
+ ok: false,
69
+ status: 404,
70
+ });
71
+
72
+ const response = await GET(mockRequest, mockParams);
73
+
74
+ expect(response).toBeInstanceOf(Response);
75
+ expect(response.status).toBe(204);
76
+ expect(await response.text()).toBe("");
77
+ });
78
+
79
+ it("should return error response with original status when graph API returns error", async () => {
80
+ // Mock error response
81
+ const errorStatus = 403;
82
+ const errorBody = "Access denied";
83
+ mockFetch.mockResolvedValueOnce({
84
+ ok: false,
85
+ status: errorStatus,
86
+ text: () => Promise.resolve(errorBody),
87
+ });
88
+
89
+ const response = await GET(mockRequest, mockParams);
90
+
91
+ expect(response).toBeInstanceOf(Response);
92
+ expect(response.status).toBe(errorStatus);
93
+ expect(await response.text()).toBe(errorBody);
94
+ });
95
+
96
+ it("should handle exceptions and return 500 error", async () => {
97
+ // Mock fetch to throw an error
98
+ const mockError = new Error("Network error");
99
+ mockFetch.mockRejectedValueOnce(mockError);
100
+
101
+ const response = await GET(mockRequest, mockParams);
102
+
103
+ expect(response).toBeInstanceOf(Response);
104
+ expect(response.status).toBe(500);
105
+ expect(await response.text()).toBe("Internal error fetching photo");
106
+ expect(consoleErrorSpy).toHaveBeenCalledWith("Photo fetch error:", mockError);
107
+ });
108
+ });
@@ -0,0 +1,45 @@
1
+ import { getGraphToken } from "./get-graph-token";
2
+
3
+ export async function GET(_request: Request, { params }: { params: { email: string } }) {
4
+ const email = params.email;
5
+
6
+ if (
7
+ !process.env.MS_GRAPH_TENANT_ID ||
8
+ !process.env.MS_GRAPH_CLIENT_ID ||
9
+ !process.env.MS_GRAPH_CLIENT_SECRET
10
+ ) {
11
+ return new Response(null, { status: 204 });
12
+ }
13
+
14
+ try {
15
+ const token = await getGraphToken();
16
+ const graphRes = await fetch(
17
+ `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(email)}/photo/$value`,
18
+ {
19
+ headers: { Authorization: `Bearer ${token}` },
20
+ },
21
+ );
22
+
23
+ if (graphRes.ok) {
24
+ return new Response(graphRes.body, {
25
+ headers: {
26
+ "Content-Type": "image/jpeg",
27
+ // browser caching (24 h, stale-while-revalidate 12 h)
28
+ "Cache-Control": "public, max-age=86400, stale-while-revalidate=43200",
29
+ },
30
+ });
31
+ }
32
+
33
+ if (graphRes.status === 404) {
34
+ return new Response(null, { status: 204 });
35
+ }
36
+
37
+ const body = await graphRes.text();
38
+
39
+ return new Response(body, { status: graphRes.status });
40
+ } catch (err) {
41
+ console.error("Photo fetch error:", err);
42
+
43
+ return new Response("Internal error fetching photo", { status: 500 });
44
+ }
45
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "acinguiux-dnr-typescript/base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": [
8
+ "src/**/*.ts",
9
+ "types.d.ts",
10
+ "node_modules/acinguiux-dnr-vitest/types.d.ts"
11
+ ]
12
+ }
package/types.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import "next-auth";
2
+ import "next-auth/jwt";
3
+ import { UserAuthorization } from "./provider";
4
+
5
+ declare module "next-auth" {
6
+ interface User {
7
+ name?: string;
8
+ email?: string;
9
+ roles?: UserAuthorization[];
10
+ }
11
+
12
+ interface Session {
13
+ expires_at?: number;
14
+ access_token?: string;
15
+ refresh_token?: string;
16
+ user?: User;
17
+ }
18
+ }
19
+
20
+ declare module "next-auth/jwt" {
21
+ interface JWT {
22
+ expires_at?: number;
23
+ access_token?: string;
24
+ refresh_token?: string;
25
+ }
26
+ }
@@ -0,0 +1,20 @@
1
+ import { default as sharedConfig } from "acinguiux-dnr-vitest/config";
2
+ import { defineConfig, mergeConfig } from "vitest/config";
3
+
4
+ const config = mergeConfig(
5
+ sharedConfig,
6
+ defineConfig({
7
+ test: {
8
+ coverage: {
9
+ thresholds: {
10
+ statements: 32,
11
+ branches: 91,
12
+ functions: 76,
13
+ lines: 32,
14
+ },
15
+ },
16
+ },
17
+ }),
18
+ );
19
+
20
+ export default config;