Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,7 @@ export class InformationSharingAgreementsController extends BaseController<Infor
}

const permittedAttributes = policy.permitAttributes(this.request.body)
await UpdateService.perform(
informationSharingAgreement,
permittedAttributes,
this.currentUser
)
await UpdateService.perform(informationSharingAgreement, permittedAttributes, this.currentUser)

const serializedInformationSharingAgreement = ShowSerializer.perform(
informationSharingAgreement
Expand Down
39 changes: 39 additions & 0 deletions api/src/controllers/users/directory-users-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { isEmpty, isNil } from "lodash"

import logger from "@/utils/logger"
import { Users } from "@/services"
import { ReferenceSerializer } from "@/serializers/users"
import BaseController from "@/controllers/base-controller"

export class DirectoryUsersController extends BaseController {
async create() {
try {
// Only internal (Yukon Government) staff name Manager contacts on agreements, matching
// who may author an ISA, so this endpoint is gated the same way. See TK-66.
if (this.currentUser.isExternal) {
return this.response.status(403).json({
message: "You are not authorized to resolve directory users",
})
}

const { email } = this.request.body
if (isNil(email) || isEmpty(email)) {
return this.response.status(422).json({
message: "email is required",
})
}

const user = await Users.EnsureFromDirectoryEmailService.perform(email, this.currentUser)
return this.response.status(201).json({
user: ReferenceSerializer.perform(user),
})
} catch (error) {
logger.error(`Error resolving directory user: ${error}`, { error })
return this.response.status(422).json({
message: `Error resolving directory user: ${error}`,
})
}
}
}

export default DirectoryUsersController
1 change: 1 addition & 0 deletions api/src/controllers/users/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { DirectorySyncController } from "./directory-sync-controller"
export { DeactivationController } from "./deactivation-controller"
export { DirectoryUsersController } from "./directory-users-controller"
3 changes: 3 additions & 0 deletions api/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ router
.delete(Notifications.ReadController.destroy)

router.route("/api/users").get(UsersController.index).post(UsersController.create)
router
.route("/api/users/directory-users")
.post(Users.DirectoryUsersController.create)
router
.route("/api/users/:id")
.get(UsersController.show)
Expand Down
52 changes: 52 additions & 0 deletions api/src/services/users/ensure-from-directory-email-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { isNil } from "lodash"

import { User } from "@/models"
import { yukonGovernmentIntegration } from "@/integrations"
import BaseService from "@/services/base-service"
import { Users } from "@/services"

/**
* Resolves a Yukon Government directory email to an internal User record, creating one
* from the directory when it does not exist yet. Used to record contract contacts (such
* as the ISA Manager) who are sourced from Active Directory. The returned user is not
* granted any group membership on its own. See TK-66.
*/
export class EnsureFromDirectoryEmailService extends BaseService {
constructor(
private email: string,
private currentUser: User
) {
super()
}

async perform(): Promise<User> {
const existingUser = await User.findOne({
where: { email: this.email },
})
if (!isNil(existingUser)) {
return existingUser
}

const employee = await yukonGovernmentIntegration.fetchEmployee(this.email)
if (isNil(employee)) {
throw new Error(`No Yukon Government directory record found for email: ${this.email}`)
}

return Users.CreateInternalService.perform(
{
email: employee.email,
firstName: employee.first_name,
lastName: employee.last_name,
displayName: employee.full_name,
department: employee.department,
division: employee.division,
branch: employee.branch,
unit: employee.unit,
title: employee.title,
},
this.currentUser
)
}
}

export default EnsureFromDirectoryEmailService
1 change: 1 addition & 0 deletions api/src/services/users/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export { DeactivateService } from "./deactivate-service"
// Special Services
export { DirectorySyncService } from "./directory-sync-service"
export { EnsureFromAuth0TokenService } from "./ensure-from-auth0-token-service"
export { EnsureFromDirectoryEmailService } from "./ensure-from-directory-email-service"
export { CreateExternalService } from "./create-external-service"
export { CreateInternalService } from "./create-internal-service"
105 changes: 105 additions & 0 deletions api/tests/controllers/users/directory-users-controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type * as Integrations from "@/integrations"

import { User } from "@/models"
import { yukonGovernmentIntegration } from "@/integrations"

import { externalOrganizationFactory, userFactory } from "@/tests/factories"
import { mockCurrentUser, request } from "@/tests/support"

vi.mock("@/integrations", async (importOriginal) => {
const actual = await importOriginal<typeof Integrations>()
return {
...actual,
yukonGovernmentIntegration: {
...actual.yukonGovernmentIntegration,
fetchEmployee: vi.fn(),
},
}
})

const mockedFetchEmployee = vi.mocked(yukonGovernmentIntegration.fetchEmployee)

describe("api/src/controllers/users/directory-users-controller.ts", () => {
describe("DirectoryUsersController", () => {
describe("#create", () => {
let currentUser: User

beforeEach(async () => {
currentUser = await userFactory.create({ isExternal: false })
mockCurrentUser(currentUser)
})

test("returns the existing internal user without creating a duplicate", async () => {
const manager = await userFactory.create({
email: "manager@example.com",
isExternal: false,
})
const userCountBefore = await User.count()

const response = await request()
.post("/api/users/directory-users")
.send({ email: "manager@example.com" })

expect(response.status).toBe(201)
expect(response.body.user.id).toBe(manager.id)
expect(response.body.user.email).toBe("manager@example.com")
expect(mockedFetchEmployee).not.toHaveBeenCalled()
expect(await User.count()).toBe(userCountBefore)
})

test("creates an internal user from the directory when the email is new", async () => {
mockedFetchEmployee.mockResolvedValue({
full_name: "Jane Manager",
first_name: "Jane",
last_name: "Manager",
organization: null,
department: "HPW",
division: null,
branch: null,
unit: null,
title: "Director",
email: "jane.manager@yukon.ca",
suite: "",
phone_office: "",
fax_office: "",
mobile: "",
office: "",
address: "",
po_box: "",
community: "",
postal_code: "",
latitude: null,
longitude: null,
mailcode: "",
manager: "",
username: "jmanager",
})

const response = await request()
.post("/api/users/directory-users")
.send({ email: "jane.manager@yukon.ca" })

expect(response.status).toBe(201)
const createdManager = await User.findOne({ where: { email: "jane.manager@yukon.ca" } })
expect(createdManager).not.toBeNull()
expect(createdManager?.isExternal).toBe(false)
expect(response.body.user.id).toBe(createdManager?.id)
})

test("returns 403 when the current user is external", async () => {
const externalOrganization = await externalOrganizationFactory.create()
const externalUser = await userFactory.create({
isExternal: true,
externalOrganizationId: externalOrganization.id,
})
mockCurrentUser(externalUser)

const response = await request()
.post("/api/users/directory-users")
.send({ email: "manager@example.com" })

expect(response.status).toBe(403)
})
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { yukonGovernmentIntegration } from "@/integrations"
import { User } from "@/models"

import { userFactory } from "@/tests/factories"

import EnsureFromDirectoryEmailService from "@/services/users/ensure-from-directory-email-service"

vi.mock("@/integrations", () => ({
yukonGovernmentIntegration: {
fetchEmployee: vi.fn(),
},
}))

const mockedYukonGovernmentIntegration = vi.mocked(yukonGovernmentIntegration)

function buildDirectoryEmployee(overrides: Partial<Record<string, unknown>> = {}) {
return {
full_name: "Jane Manager",
first_name: "Jane",
last_name: "Manager",
organization: null,
department: "HPW",
division: "Digital Services",
branch: "Applications",
unit: "Platform",
title: "Director",
email: "jane.manager@yukon.ca",
suite: "",
phone_office: "",
fax_office: "",
mobile: "",
office: "",
address: "",
po_box: "",
community: "",
postal_code: "",
latitude: null,
longitude: null,
mailcode: "",
manager: "",
username: "jmanager",
...overrides,
}
}

describe("api/src/services/users/ensure-from-directory-email-service.ts", () => {
describe("EnsureFromDirectoryEmailService", () => {
describe("#perform", () => {
test("when a user with the email already exists, returns it without touching the directory", async () => {
const currentUser = await userFactory.create()
const existingUser = await userFactory.create({ email: "manager@example.com" })

const result = await EnsureFromDirectoryEmailService.perform(
"manager@example.com",
currentUser
)

expect(result.id).toEqual(existingUser.id)
expect(mockedYukonGovernmentIntegration.fetchEmployee).not.toHaveBeenCalled()
})

test("when no user exists, creates an internal user from the directory record", async () => {
const currentUser = await userFactory.create()
mockedYukonGovernmentIntegration.fetchEmployee.mockResolvedValue(buildDirectoryEmployee())

const result = await EnsureFromDirectoryEmailService.perform(
"jane.manager@yukon.ca",
currentUser
)

expect(result).toBeInstanceOf(User)
expect(result.email).toEqual("jane.manager@yukon.ca")
expect(result.isExternal).toEqual(false)
expect(result.department).toEqual("HPW")
expect(result.title).toEqual("Director")
})

test("when no user exists and the directory has no matching record, throws", async () => {
const currentUser = await userFactory.create()
mockedYukonGovernmentIntegration.fetchEmployee.mockResolvedValue(null)

await expect(
EnsureFromDirectoryEmailService.perform("missing@yukon.ca", currentUser)
).rejects.toThrow("No Yukon Government directory record found")
})
})
})
})
5 changes: 5 additions & 0 deletions web/src/api/users-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ export const usersApi = {
const { data } = await http.post(`/api/users/${userId}/directory-sync`)
return data
},

async ensureFromDirectory(email: string): Promise<{ user: UserAsReference }> {
const { data } = await http.post("/api/users/directory-users", { email })
return data
},
}

export default usersApi
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,13 @@
cols="12"
md="6"
>
<UserSearchableAutocomplete
:model-value="internalGroupSecondaryContactId"
<YukonGovernmentEmployeeSearchableAutocomplete
:model-value="internalGroupSecondaryContactEmail"
label="Yukon Government (YG) Manager Contact Name *"
:where="internalGroupSecondaryContactWhere"
hint="Typically the manager of the primary YG contact, but can be any appropriate internal contact."
hint="Typically the manager of the primary YG contact, but can be any appropriate internal contact. Search the Active Directory."
:rules="[required]"
required
@update:model-value="emit('update:internalGroupSecondaryContactId', $event)"
@update:model-value="emit('update:internalGroupSecondaryContactEmail', $event)"
/>
</v-col>
</v-row>
Expand All @@ -140,6 +139,7 @@ import useUser from "@/use/use-user"
import UserSearchableAutocomplete, {
type UserAsIndex,
} from "@/components/users/UserSearchableAutocomplete.vue"
import YukonGovernmentEmployeeSearchableAutocomplete from "@/components/yukon-government-directory/YukonGovernmentEmployeeSearchableAutocomplete.vue"

const props = defineProps<{
title: string | null | undefined
Expand All @@ -148,7 +148,7 @@ const props = defineProps<{
externalGroupContactTitle: string | null | undefined
internalGroupContactId: number | null | undefined
internalGroupContactTitle: string | null | undefined
internalGroupSecondaryContactId: number | null | undefined
internalGroupSecondaryContactEmail: string | null | undefined
}>()

const emit = defineEmits<{
Expand All @@ -158,7 +158,7 @@ const emit = defineEmits<{
"update:externalGroupContactTitle": [value: string | null | undefined]
"update:internalGroupContactId": [value: number | null | undefined]
"update:internalGroupContactTitle": [value: string | null | undefined]
"update:internalGroupSecondaryContactId": [value: number | null | undefined]
"update:internalGroupSecondaryContactEmail": [value: string | null | undefined]
}>()

const { externalGroupContactId } = toRefs(props)
Expand All @@ -183,9 +183,6 @@ const externalGroupContactWhere = computed(() => ({
const internalGroupContactWhere = computed(() => ({
isExternal: false,
}))
const internalGroupSecondaryContactWhere = computed(() => ({
isExternal: false,
}))

function updateExternalGroupContactTitle(user: UserAsIndex | null) {
if (isNil(user)) {
Expand Down
Loading