diff --git a/packages/nextjs/src/server/actions/__tests__/createOrganization.test.ts b/packages/nextjs/src/server/actions/__tests__/createOrganization.test.ts new file mode 100644 index 00000000..baa5837d --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/createOrganization.test.ts @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; + +// Adjust these paths if your project structure is different +import createOrganization from '../createOrganization'; + +// Use the same class so we can assert instanceof and status code propagation +import { AsgardeoAPIError, Organization, CreateOrganizationPayload } from '@asgardeo/node'; + +// ---- Mocks ---- +vi.mock('../../../AsgardeoNextClient', () => { + // We return a default export with a static getInstance function we can stub + return { + default: { + getInstance: vi.fn(), + }, + }; +}); + +vi.mock('../getSessionId', () => ({ + default: vi.fn(), +})); + +// Pull the mocked modules so we can access their spies +import AsgardeoNextClient from '../../../AsgardeoNextClient'; +import getSessionId from '../getSessionId'; + +describe('createOrganization (Next.js server action)', () => { + const mockClient = { + createOrganization: vi.fn(), + }; + + const basePayload: CreateOrganizationPayload = { + name: 'Team Viewer', + orgHandle: 'team-viewer', + description: 'Screen sharing organization', + parentId: 'parent-123', + type: 'TENANT', + }; + + const mockOrg: Organization = { + id: 'org-001', + name: 'Team Viewer', + orgHandle: 'team-viewer', + }; + + beforeEach(() => { + vi.resetAllMocks(); + + // Default: getInstance returns our mock client + (AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(mockClient); + // Default: getSessionId resolves to a session id + (getSessionId as unknown as Mock).mockResolvedValue('sess-abc'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should create an organization successfully when a sessionId is provided', async () => { + mockClient.createOrganization.mockResolvedValueOnce(mockOrg); + + const result = await createOrganization(basePayload, 'sess-123'); + + expect(AsgardeoNextClient.getInstance).toHaveBeenCalledTimes(1); + expect(getSessionId).not.toHaveBeenCalled(); + expect(mockClient.createOrganization).toHaveBeenCalledWith(basePayload, 'sess-123'); + expect(result).toEqual(mockOrg); + }); + + it('should fall back to getSessionId when sessionId is undefined', async () => { + mockClient.createOrganization.mockResolvedValueOnce(mockOrg); + + const result = await createOrganization(basePayload, undefined as unknown as string); + + expect(getSessionId).toHaveBeenCalledTimes(1); + expect(mockClient.createOrganization).toHaveBeenCalledWith(basePayload, 'sess-abc'); + expect(result).toEqual(mockOrg); + }); + + it('should fall back to getSessionId when sessionId is null', async () => { + mockClient.createOrganization.mockResolvedValueOnce(mockOrg); + + const result = await createOrganization(basePayload, null as unknown as string); + + expect(getSessionId).toHaveBeenCalledTimes(1); + expect(mockClient.createOrganization).toHaveBeenCalledWith(basePayload, 'sess-abc'); + expect(result).toEqual(mockOrg); + }); + + it('should not call getSessionId when an empty string is passed (empty string is not nullish)', async () => { + mockClient.createOrganization.mockResolvedValueOnce(mockOrg); + + const result = await createOrganization(basePayload, ''); + + expect(getSessionId).not.toHaveBeenCalled(); + expect(mockClient.createOrganization).toHaveBeenCalledWith(basePayload, ''); + expect(result).toEqual(mockOrg); + }); + + it('should wrap an AsgardeoAPIError thrown by client.createOrganization, preserving statusCode', async () => { + const original = new AsgardeoAPIError( + 'Upstream validation failed', + 'ORG_CREATE_400', + 'server', + 400 + ); + mockClient.createOrganization.mockRejectedValueOnce(original); + + await expect(createOrganization(basePayload, 'sess-1')).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: 400, + message: expect.stringContaining('Failed to create the organization: Upstream validation failed'), + }); + }); +}); diff --git a/packages/nextjs/src/server/actions/__tests__/getAccessToken.test.ts b/packages/nextjs/src/server/actions/__tests__/getAccessToken.test.ts new file mode 100644 index 00000000..1e977113 --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/getAccessToken.test.ts @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// src/server/actions/__tests__/getAccessToken.test.ts +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; + +// SUT +import getAccessToken from '../getAccessToken'; + +// ---- Mocks ---- +vi.mock('next/headers', () => { + return { + cookies: vi.fn(), + }; +}); + +vi.mock('../../../utils/SessionManager', () => { + return { + default: { + getSessionCookieName: vi.fn(), + verifySessionToken: vi.fn(), + }, + }; +}); + +// Pull mocked modules so we can control them +import { cookies } from 'next/headers'; +import SessionManager from '../../../utils/SessionManager'; + +// A tiny helper type for the cookie store the SUT expects +type CookieVal = { value: string }; +type CookieStore = { get: (name: string) => CookieVal | undefined }; + +describe('getAccessToken', () => { + const SESSION_COOKIE_NAME = 'app_session'; + + const makeCookieStore = (map: Record): CookieStore => ({ + get: (name: string) => { + const v = map[name]; + return typeof v === 'string' ? { value: v } : undefined; + }, + }); + + beforeEach(() => { + vi.resetAllMocks(); + + // Default cookie name + (SessionManager.getSessionCookieName as unknown as Mock).mockReturnValue(SESSION_COOKIE_NAME); + + // Default cookies() returns an object with get() + (cookies as unknown as Mock).mockResolvedValue(makeCookieStore({})); + + // Default verification returns an object with a string token + (SessionManager.verifySessionToken as unknown as Mock).mockResolvedValue({ + accessToken: 'tok-123', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return the access token when the session cookie exists and verification succeeds', async () => { + // Arrange + (cookies as unknown as Mock).mockResolvedValue( + makeCookieStore({ [SESSION_COOKIE_NAME]: 'signed.jwt.token' }), + ); + + // Act + const token = await getAccessToken(); + + // Assert + expect(SessionManager.getSessionCookieName).toHaveBeenCalledTimes(1); + expect(cookies).toHaveBeenCalledTimes(1); + expect(SessionManager.verifySessionToken).toHaveBeenCalledWith('signed.jwt.token'); + expect(token).toBe('tok-123'); + }); + + it('should return undefined when the session cookie is missing', async () => { + // Arrange: no cookie present (default makeCookieStore({})) + // Act + const token = await getAccessToken(); + + // Assert + expect(SessionManager.getSessionCookieName).toHaveBeenCalledTimes(1); + expect(SessionManager.verifySessionToken).not.toHaveBeenCalled(); + expect(token).toBeUndefined(); + }); + + it('should return undefined when the session cookie value is an empty string', async () => { + (cookies as unknown as Mock).mockResolvedValue( + makeCookieStore({ [SESSION_COOKIE_NAME]: '' }), + ); + + const token = await getAccessToken(); + + expect(SessionManager.verifySessionToken).not.toHaveBeenCalled(); + expect(token).toBeUndefined(); + }); + + it('should return undefined when verifySessionToken throws (invalid or expired session)', async () => { + (cookies as unknown as Mock).mockResolvedValue( + makeCookieStore({ [SESSION_COOKIE_NAME]: 'bad.token' }), + ); + (SessionManager.verifySessionToken as unknown as Mock).mockRejectedValue( + new Error('invalid signature'), + ); + + const token = await getAccessToken(); + + expect(SessionManager.verifySessionToken).toHaveBeenCalledWith('bad.token'); + expect(token).toBeUndefined(); + }); + + it('should return undefined when verification succeeds but accessToken is missing', async () => { + (cookies as unknown as Mock).mockResolvedValue( + makeCookieStore({ [SESSION_COOKIE_NAME]: 'signed.jwt.token' }), + ); + (SessionManager.verifySessionToken as unknown as Mock).mockResolvedValue({ + // no accessToken field + sub: 'user@tenant', + }); + + const token = await getAccessToken(); + + expect(token).toBeUndefined(); + }); + + +}); diff --git a/packages/nextjs/src/server/actions/__tests__/getAllOrganizations.test.ts b/packages/nextjs/src/server/actions/__tests__/getAllOrganizations.test.ts new file mode 100644 index 00000000..0bbd82f1 --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/getAllOrganizations.test.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// src/server/actions/__tests__/getAllOrganizations.test.ts +import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; + +// --- Mocks MUST be defined before importing the SUT --- +vi.mock('../../../AsgardeoNextClient', () => ({ + default: { + getInstance: vi.fn(), + }, +})); + +vi.mock('../getSessionId', () => ({ + default: vi.fn(), +})); + +// --- Now import the SUT and mocked deps --- +import getAllOrganizations from '../getAllOrganizations'; +import AsgardeoNextClient from '../../../AsgardeoNextClient'; +import getSessionId from '../getSessionId'; +import { AsgardeoAPIError, AllOrganizationsApiResponse } from '@asgardeo/node'; + +describe('getAllOrganizations (Next.js server action)', () => { + const mockClient = { + getAllOrganizations: vi.fn(), + }; + + const baseOptions = { limit: 50, cursor: 'cur-1', filter: 'type eq "TENANT"' }; + + const mockResponse: AllOrganizationsApiResponse = { + data: [ + { id: 'org-001', name: 'Alpha', orgHandle: 'alpha' }, + { id: 'org-002', name: 'Beta', orgHandle: 'beta' }, + ], + meta: { totalResults: 2, startIndex: 1, itemsPerPage: 2 }, + } as unknown as AllOrganizationsApiResponse; + + beforeEach(() => { + vi.resetAllMocks(); + + // Default: getInstance returns our mock client + (AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(mockClient); + // Default: session id resolver returns a value + (getSessionId as unknown as Mock).mockResolvedValue('sess-abc'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns organizations when a sessionId is provided (no getSessionId fallback)', async () => { + mockClient.getAllOrganizations.mockResolvedValueOnce(mockResponse); + + const result = await getAllOrganizations(baseOptions, 'sess-123'); + + expect(AsgardeoNextClient.getInstance).toHaveBeenCalledTimes(1); + expect(getSessionId).not.toHaveBeenCalled(); + expect(mockClient.getAllOrganizations).toHaveBeenCalledWith(baseOptions, 'sess-123'); + expect(result).toBe(mockResponse); + }); + + it('falls back to getSessionId when sessionId is undefined', async () => { + mockClient.getAllOrganizations.mockResolvedValueOnce(mockResponse); + + const result = await getAllOrganizations(baseOptions, undefined); + + expect(getSessionId).toHaveBeenCalledTimes(1); + expect(mockClient.getAllOrganizations).toHaveBeenCalledWith(baseOptions, 'sess-abc'); + expect(result).toBe(mockResponse); + }); + + it('falls back to getSessionId when sessionId is null', async () => { + mockClient.getAllOrganizations.mockResolvedValueOnce(mockResponse); + + const result = await getAllOrganizations(baseOptions, null as unknown as string); + + expect(getSessionId).toHaveBeenCalledTimes(1); + expect(mockClient.getAllOrganizations).toHaveBeenCalledWith(baseOptions, 'sess-abc'); + expect(result).toBe(mockResponse); + }); + + it('does not call getSessionId for an empty string sessionId (empty string is not nullish)', async () => { + mockClient.getAllOrganizations.mockResolvedValueOnce(mockResponse); + + const result = await getAllOrganizations(baseOptions, ''); + + expect(getSessionId).not.toHaveBeenCalled(); + expect(mockClient.getAllOrganizations).toHaveBeenCalledWith(baseOptions, ''); + expect(result).toBe(mockResponse); + }); + + it('wraps an AsgardeoAPIError thrown by client.getAllOrganizations, preserving statusCode', async () => { + const upstream = new AsgardeoAPIError( + 'Upstream failed', + 'ORG_LIST_500', + 'server', + 503, + ); + mockClient.getAllOrganizations.mockRejectedValueOnce(upstream); + + await expect(getAllOrganizations(baseOptions, 'sess-x')).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: 503, + message: expect.stringContaining('Failed to get all the organizations for the user: Upstream failed'), + }); + }); + +}); + diff --git a/packages/nextjs/src/server/actions/__tests__/getBrandingPreference.test.ts b/packages/nextjs/src/server/actions/__tests__/getBrandingPreference.test.ts new file mode 100644 index 00000000..bff80c18 --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/getBrandingPreference.test.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// src/server/actions/__tests__/getBrandingPreference.test.ts +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +// Mock the upstream module first. Keep all dependencies inside the factory. +vi.mock('@asgardeo/node', () => { + const getBrandingPreference = vi.fn(); + + class MockAsgardeoAPIError extends Error { + code?: string; + source?: string; + statusCode?: number; + constructor(message: string, code?: string, source?: string, statusCode?: number) { + super(message); + this.name = 'AsgardeoAPIError'; + this.code = code; + this.source = source; + this.statusCode = statusCode; + } + } + + return { + AsgardeoAPIError: MockAsgardeoAPIError, + getBrandingPreference, + }; +}); + +// Now import SUT and mocked exports +import getBrandingPreference from '../getBrandingPreference'; +import { + AsgardeoAPIError, + getBrandingPreference as baseGetBrandingPreference, +} from '@asgardeo/node'; + +describe('getBrandingPreference (Next.js server action)', () => { + type BrandingPreference = Awaited>; + type Cfg = Parameters[0]; + + const cfg: Cfg = { orgId: 'org-001', locale: 'en-US' } as unknown as Cfg; + + const mockPref: BrandingPreference = { + theme: { colors: { primary: '#0055aa' } }, + logoUrl: 'https://cdn.example.com/logo.png', + } as unknown as BrandingPreference; + + beforeEach(() => { + vi.resetAllMocks(); + (baseGetBrandingPreference as unknown as Mock).mockResolvedValue(mockPref); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return branding preferences when upstream succeeds', async () => { + const result = await getBrandingPreference(cfg, 'sess-123'); + + expect(baseGetBrandingPreference).toHaveBeenCalledTimes(1); + expect(baseGetBrandingPreference).toHaveBeenCalledWith(cfg); + + // Ensure sessionId is not forwarded + const call = (baseGetBrandingPreference as unknown as Mock).mock.calls[0]; + expect(call.length).toBe(1); + + expect(result).toBe(mockPref); + }); + + it('should wrap an AsgardeoAPIError from upstream, preserving statusCode', async () => { + const upstream = new AsgardeoAPIError('Not found', 'BRAND_404', 'server', 404); + (baseGetBrandingPreference as unknown as Mock).mockRejectedValueOnce(upstream); + + await expect(getBrandingPreference(cfg)).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: 404, + message: expect.stringContaining('Failed to get branding preferences: Not found'), + }); + }); + + it('should wrap a generic Error with undefined statusCode', async () => { + (baseGetBrandingPreference as unknown as Mock).mockRejectedValueOnce( + new Error('network down'), + ); + + await expect(getBrandingPreference(cfg)).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: undefined, + message: expect.stringContaining('Failed to get branding preferences: network down'), + }); + }); + + it('should wrap a non-Error rejection value using String(error)', async () => { + (baseGetBrandingPreference as unknown as Mock).mockRejectedValueOnce('boom'); + + await expect(getBrandingPreference(cfg)).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: undefined, + message: expect.stringContaining('Failed to get branding preferences: boom'), + }); + }); +}); diff --git a/packages/nextjs/src/server/actions/__tests__/getClientOrigin.test.ts b/packages/nextjs/src/server/actions/__tests__/getClientOrigin.test.ts new file mode 100644 index 00000000..3f788d3f --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/getClientOrigin.test.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// src/server/actions/__tests__/getClientOrigin.test.ts +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +//Mock next/headers BEFORE importing the SUT +vi.mock('next/headers', () => ({ + headers: vi.fn(), +})); + +//Import SUT and mocked dep +import getClientOrigin from '../getClientOrigin'; +import { headers } from 'next/headers'; + +// Helper: build a Headers-like object. get() should be case-insensitive. +type HLike = { get: (name: string) => string | null }; +const makeHeaders = (map: Record): HLike => { + const normalized: Record = {}; + for (const [k, v] of Object.entries(map)) normalized[k.toLowerCase()] = v; + return { + get: (name: string) => { + const v = normalized[name.toLowerCase()]; + return v == null ? null : v; // emulate real Headers.get(): string | null + }, + }; +}; + +describe('getClientOrigin', () => { + beforeEach(() => { + vi.resetAllMocks(); + // by default return empty headers + (headers as unknown as Mock).mockResolvedValue(makeHeaders({})); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return https origin when x-forwarded-proto is https and host is present', async () => { + (headers as unknown as Mock).mockResolvedValue( + makeHeaders({ host: 'example.com', 'x-forwarded-proto': 'https' }), + ); + + const origin = await getClientOrigin(); + + expect(headers).toHaveBeenCalledTimes(1); + expect(origin).toBe('https://example.com'); + }); + + it('should fall back to http when x-forwarded-proto is missing', async () => { + (headers as unknown as Mock).mockResolvedValue( + makeHeaders({ host: 'svc.internal' /* x-forwarded-proto: missing */ }), + ); + + const origin = await getClientOrigin(); + + expect(origin).toBe('http://svc.internal'); + }); + + it('should return "protocol://null" when host is missing', async () => { + // host header absent -> get('host') returns null -> interpolates as "null" + (headers as unknown as Mock).mockResolvedValue( + makeHeaders({ 'x-forwarded-proto': 'https' }), + ); + + const origin = await getClientOrigin(); + + expect(origin).toBe('https://null'); + }); + + it('should propagate errors when headers() rejects', async () => { + (headers as unknown as Mock).mockRejectedValue(new Error('headers not available')); + + await expect(getClientOrigin()).rejects.toThrow('headers not available'); + }); +}); + diff --git a/packages/nextjs/src/server/actions/__tests__/getCurrentOrganizationAction.test.ts b/packages/nextjs/src/server/actions/__tests__/getCurrentOrganizationAction.test.ts new file mode 100644 index 00000000..43bb924a --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/getCurrentOrganizationAction.test.ts @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// src/server/actions/__tests__/getCurrentOrganizationAction.test.ts +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +// --- Mock client factory BEFORE importing SUT --- +vi.mock('../../../AsgardeoNextClient', () => ({ + default: { + getInstance: vi.fn(), + }, +})); + +// --- Import SUT and mocked deps --- +import getCurrentOrganizationAction from '../getCurrentOrganizationAction'; +import AsgardeoNextClient from '../../../AsgardeoNextClient'; + +// A light org shape for testing (only fields we assert on) +type Org = { id: string; name: string; orgHandle?: string }; + +describe('getCurrentOrganizationAction', () => { + const mockClient = { + getCurrentOrganization: vi.fn(), + }; + + const sessionId = 'sess-123'; + const org: Org = { id: 'org-001', name: 'Alpha', orgHandle: 'alpha' }; + + beforeEach(() => { + vi.resetAllMocks(); + (AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(mockClient); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns success with organization when upstream succeeds', async () => { + mockClient.getCurrentOrganization.mockResolvedValueOnce(org); + + const result = await getCurrentOrganizationAction(sessionId); + + expect(AsgardeoNextClient.getInstance).toHaveBeenCalledTimes(1); + expect(mockClient.getCurrentOrganization).toHaveBeenCalledWith(sessionId); + + expect(result.success).toBe(true); + expect(result.error).toBeNull(); + expect(result.data.organization).toEqual(org); + }); + + it('should pass through the provided sessionId even if it is an empty string', async () => { + mockClient.getCurrentOrganization.mockResolvedValueOnce(org); + + const result = await getCurrentOrganizationAction(''); + + expect(mockClient.getCurrentOrganization).toHaveBeenCalledWith(''); + expect(result.success).toBe(true); + expect(result.data.organization).toEqual(org); + }); + + it('should return failure shape when client.getCurrentOrganization rejects', async () => { + mockClient.getCurrentOrganization.mockRejectedValueOnce(new Error('upstream down')); + + const result = await getCurrentOrganizationAction(sessionId); + + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to get the current organization'); + // Matches the function’s failure payload shape + expect(result.data).toEqual({ user: {} }); + }); + + it('should return failure shape when AsgardeoNextClient.getInstance throws', async () => { + (AsgardeoNextClient.getInstance as unknown as Mock).mockImplementationOnce(() => { + throw new Error('factory failed'); + }); + + const result = await getCurrentOrganizationAction(sessionId); + + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to get the current organization'); + expect(result.data).toEqual({ user: {} }); + }); + + it('should not mutate the organization object returned by upstream', async () => { + const upstreamOrg = { ...org, extra: { nested: true } }; + mockClient.getCurrentOrganization.mockResolvedValueOnce(upstreamOrg); + + const result = await getCurrentOrganizationAction(sessionId); + + // exact deep equality: whatever upstream returns is passed through + expect(result.data.organization).toEqual(upstreamOrg); + }); +}); diff --git a/packages/nextjs/src/server/actions/__tests__/getMyOrganizations.test.ts b/packages/nextjs/src/server/actions/__tests__/getMyOrganizations.test.ts new file mode 100644 index 00000000..199b9e19 --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/getMyOrganizations.test.ts @@ -0,0 +1,205 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// src/server/actions/__tests__/getMyOrganizations.test.ts +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +// --- Mocks (declare BEFORE importing the SUT) --- +vi.mock('../../../AsgardeoNextClient', () => ({ + default: { + getInstance: vi.fn(), + }, +})); + +// Mock the dynamically-imported module that SUT calls as: import('./getSessionId') +vi.mock('../getSessionId', () => ({ + default: vi.fn(), +})); + +// --- Import SUT and mocked deps --- +import getMyOrganizations from '../getMyOrganizations'; +import AsgardeoNextClient from '../../../AsgardeoNextClient'; +import getSessionId from '../getSessionId'; +import { AsgardeoAPIError } from '@asgardeo/node'; + +describe('getMyOrganizations (Next.js server action)', () => { + const mockClient = { + getAccessToken: vi.fn(), + getMyOrganizations: vi.fn(), + }; + + const options = { limit: 25, filter: 'type eq "TENANT"' }; + const orgs = [ + { id: 'org-1', name: 'Alpha', orgHandle: 'alpha' }, + { id: 'org-2', name: 'Beta', orgHandle: 'beta' }, + ]; + + beforeEach(() => { + vi.resetAllMocks(); + (AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(mockClient); + (getSessionId as unknown as Mock).mockResolvedValue('sess-abc'); + mockClient.getAccessToken.mockResolvedValue('atk-123'); + mockClient.getMyOrganizations.mockResolvedValue(orgs); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return organizations when sessionId is provided (no getSessionId fallback)', async () => { + const result = await getMyOrganizations(options, 'sess-123'); + + expect(AsgardeoNextClient.getInstance).toHaveBeenCalledTimes(1); + expect(getSessionId).not.toHaveBeenCalled(); + expect(mockClient.getAccessToken).toHaveBeenCalledWith('sess-123'); + expect(mockClient.getMyOrganizations).toHaveBeenCalledWith(options, 'sess-123'); + expect(result).toEqual(orgs); + }); + + it('should fall back to getSessionId when sessionId is undefined', async () => { + const result = await getMyOrganizations(options, undefined); + + expect(getSessionId).toHaveBeenCalledTimes(1); + expect(mockClient.getAccessToken).toHaveBeenCalledWith('sess-abc'); + expect(mockClient.getMyOrganizations).toHaveBeenCalledWith(options, 'sess-abc'); + expect(result).toEqual(orgs); + }); + + it('should fall back to getSessionId when sessionId is null', async () => { + const result = await getMyOrganizations(options, null as unknown as string); + + expect(getSessionId).toHaveBeenCalledTimes(1); + expect(mockClient.getAccessToken).toHaveBeenCalledWith('sess-abc'); + expect(mockClient.getMyOrganizations).toHaveBeenCalledWith(options, 'sess-abc'); + expect(result).toEqual(orgs); + }); + + it('should treat empty string sessionId as falsy and calls getSessionId', async () => { + const result = await getMyOrganizations(options, ''); + + expect(getSessionId).toHaveBeenCalledTimes(1); + expect(mockClient.getAccessToken).toHaveBeenCalledWith('sess-abc'); + expect(mockClient.getMyOrganizations).toHaveBeenCalledWith(options, 'sess-abc'); + expect(result).toEqual(orgs); + }); + + it('should pass through undefined options', async () => { + const result = await getMyOrganizations(undefined, 'sess-123'); + + expect(mockClient.getMyOrganizations).toHaveBeenCalledWith(undefined, 'sess-123'); + expect(result).toEqual(orgs); + }); + + it('should throw AsgardeoAPIError(401) when no session can be resolved', async () => { + (getSessionId as unknown as Mock).mockResolvedValueOnce(''); + + await expect(getMyOrganizations(options, undefined)).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: 401, + message: expect.stringContaining('Failed to get the organizations for the user: No session ID available'), + }); + + // Should fail before calling client methods + expect(mockClient.getAccessToken).not.toHaveBeenCalled(); + expect(mockClient.getMyOrganizations).not.toHaveBeenCalled(); + }); + + it('should throw AsgardeoAPIError(401) when access token resolves to undefined (not signed in)', async () => { + mockClient.getAccessToken.mockResolvedValueOnce(undefined); + + await expect(getMyOrganizations(options, 'sess-123')).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: 401, + message: expect.stringContaining('Failed to get the organizations for the user: User is not signed in - access token retrieval failed'), + }); + + expect(mockClient.getAccessToken).toHaveBeenCalledWith('sess-123'); + expect(console.error).toHaveBeenCalled(); // inner catch logs + expect(mockClient.getMyOrganizations).not.toHaveBeenCalled(); + }); + + it('should treat empty-string access token as not signed in (401)', async () => { + mockClient.getAccessToken.mockResolvedValueOnce(''); + + await expect(getMyOrganizations(options, 'sess-123')).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: 401, + message: expect.stringContaining('Failed to get the organizations for the user: User is not signed in - access token retrieval failed'), + }); + + expect(console.error).toHaveBeenCalled(); + expect(mockClient.getMyOrganizations).not.toHaveBeenCalled(); + }); + + it('should throw AsgardeoAPIError(401) when getAccessToken throws (e.g., upstream failure)', async () => { + mockClient.getAccessToken.mockRejectedValueOnce(new Error('token endpoint down')); + + await expect(getMyOrganizations(options, 'sess-123')).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: 401, + message: expect.stringContaining('Failed to get the organizations for the user: User is not signed in - access token retrieval failed'), + }); + + expect(console.error).toHaveBeenCalled(); + expect(mockClient.getMyOrganizations).not.toHaveBeenCalled(); + }); + + it('should wrap an AsgardeoAPIError from client.getMyOrganizations, preserving statusCode', async () => { + const upstream = new AsgardeoAPIError('Upstream failed', 'ORG_LIST_503', 'server', 503); + mockClient.getMyOrganizations.mockRejectedValueOnce(upstream); + + await expect(getMyOrganizations(options, 'sess-123')).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: 503, + message: expect.stringContaining('Failed to get the organizations for the user: Upstream failed'), + }); + }); + + it('should wrap a generic Error from client.getMyOrganizations with undefined statusCode', async () => { + mockClient.getMyOrganizations.mockRejectedValueOnce(new Error('network down')); + + await expect(getMyOrganizations(options, 'sess-123')).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: undefined, + message: expect.stringContaining('Failed to get the organizations for the user: network down'), + }); + }); + + it('should wrap an error thrown by AsgardeoNextClient.getInstance()', async () => { + (AsgardeoNextClient.getInstance as unknown as Mock).mockImplementationOnce(() => { + throw new Error('factory failed'); + }); + + await expect(getMyOrganizations(options, 'sess-123')).rejects.toMatchObject({ + constructor: AsgardeoAPIError, + statusCode: undefined, + message: expect.stringContaining('Failed to get the organizations for the user: factory failed'), + }); + }); + + it('should handle minimal call: no options, undefined sessionId -> resolves via getSessionId and succeeds', async () => { + const result = await getMyOrganizations(); + + expect(getSessionId).toHaveBeenCalledTimes(1); + expect(mockClient.getAccessToken).toHaveBeenCalledWith('sess-abc'); + expect(mockClient.getMyOrganizations).toHaveBeenCalledWith(undefined, 'sess-abc'); + expect(result).toEqual(orgs); + }); +}); + diff --git a/packages/nextjs/src/server/actions/__tests__/getOrganizationAction.test.ts b/packages/nextjs/src/server/actions/__tests__/getOrganizationAction.test.ts new file mode 100644 index 00000000..7d117b2a --- /dev/null +++ b/packages/nextjs/src/server/actions/__tests__/getOrganizationAction.test.ts @@ -0,0 +1,106 @@ +// src/server/actions/__tests__/getOrganizationAction.test.ts +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; + +// Mock client factory BEFORE importing the SUT +vi.mock('../../../AsgardeoNextClient', () => ({ + default: { + getInstance: vi.fn(), + }, +})); + +// Import SUT and mocked deps +import getOrganizationAction from '../getOrganizationAction'; +import AsgardeoNextClient from '../../../AsgardeoNextClient'; + +// Minimal shape for testing; add fields only if you assert on them +type OrganizationDetails = { id: string; name: string; orgHandle?: string }; + +describe('getOrganizationAction', () => { + const mockClient = { + getOrganization: vi.fn(), + }; + + const orgId = 'org-001'; + const sessionId = 'sess-123'; + const org: OrganizationDetails = { id: orgId, name: 'Alpha', orgHandle: 'alpha' }; + + beforeEach(() => { + vi.resetAllMocks(); + (AsgardeoNextClient.getInstance as unknown as Mock).mockReturnValue(mockClient); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return success with organization when upstream succeeds', async () => { + mockClient.getOrganization.mockResolvedValueOnce(org); + + const result = await getOrganizationAction(orgId, sessionId); + + expect(AsgardeoNextClient.getInstance).toHaveBeenCalledTimes(1); + expect(mockClient.getOrganization).toHaveBeenCalledWith(orgId, sessionId); + + expect(result).toEqual({ + success: true, + data: { organization: org }, + error: null, + }); + }); + + it('should pass through empty-string organizationId and sessionId (documents current behavior)', async () => { + mockClient.getOrganization.mockResolvedValueOnce(org); + + const result = await getOrganizationAction('', ''); + + expect(mockClient.getOrganization).toHaveBeenCalledWith('', ''); + expect(result.success).toBe(true); + expect(result.data.organization).toEqual(org); + }); + + it('should return failure shape when client.getOrganization rejects', async () => { + mockClient.getOrganization.mockRejectedValueOnce(new Error('upstream down')); + + const result = await getOrganizationAction(orgId, sessionId); + + expect(result).toEqual({ + success: false, + data: { user: {} }, + error: 'Failed to get organization', + }); + }); + + it('should return failure shape when AsgardeoNextClient.getInstance throws', async () => { + (AsgardeoNextClient.getInstance as unknown as Mock).mockImplementationOnce(() => { + throw new Error('factory failed'); + }); + + const result = await getOrganizationAction(orgId, sessionId); + + expect(result).toEqual({ + success: false, + data: { user: {} }, + error: 'Failed to get organization', + }); + }); + + it('should return failure shape when client rejects with a non-Error value', async () => { + mockClient.getOrganization.mockRejectedValueOnce('bad'); + const result = await getOrganizationAction(orgId, sessionId); + expect(result).toEqual({ + success: false, + data: { user: {} }, + error: 'Failed to get organization', + }); +}); + + + it('should not mutate the organization object returned by upstream', async () => { + const upstreamOrg = { ...org, extra: { nested: true } }; + mockClient.getOrganization.mockResolvedValueOnce(upstreamOrg); + + const result = await getOrganizationAction(orgId, sessionId); + + expect(result.data.organization).toEqual(upstreamOrg); + }); +});