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
136 changes: 136 additions & 0 deletions VueApp/src/Students/__tests__/student-class-year-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { nextTick } from "vue"
import { setActivePinia, createPinia } from "pinia"
import { mount } from "@vue/test-utils"
import { Quasar } from "quasar"
import { useUserStore } from "@/store/UserStore"
import StudentClassYear from "@/Students/pages/StudentClassYear.vue"
import { routes } from "../router/routes"

// Every class-year mutation requires SVMSecure.SIS.AllStudents on the server, so a user with
// only SVMSecure.Students must not be offered controls that would come back 403.
const SIS = "SVMSecure.SIS.AllStudents"
const STUDENTS_ONLY = ["SVMSecure.Students"]

vi.mock("@/composables/ViperFetch", () => ({
useFetch: () => ({
get: vi.fn<(...args: unknown[]) => unknown>().mockResolvedValue({ success: true, result: [] }),
put: vi.fn<(...args: unknown[]) => unknown>(),
del: vi.fn<(...args: unknown[]) => unknown>(),
}),
}))

vi.mock("vue-router", () => ({
useRoute: () => ({ query: {} }),
}))

function signInWith(permissions: string[]) {
setActivePinia(createPinia())
useUserStore().setPermissions(permissions)
}

function mountPage() {
return mount(StudentClassYear, {
global: {
plugins: [[Quasar, {}]],
provide: { apiURL: import.meta.env.VITE_API_URL, viperOneUrl: "http://localhost/" },
// QDialog teleports to body; render it inline so its contents are assertable.
stubs: { StatusBadge: true, teleport: true },
},
Comment thread
rlorenzo marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}

function enterClassYearRoute(query: Record<string, string>) {
const classYearRoute = routes.find((r) => r.path === "/Students/StudentClassYear")
const guard = classYearRoute?.beforeEnter as (to: { query: Record<string, string> }) => unknown

return guard({ query })
}

// The update dialog only exists once a class year is selected.
async function mountPageWithDialogOpen() {
const wrapper = mountPage()
;(wrapper.vm as unknown as { showForm: boolean }).showForm = true
await nextTick()
return wrapper
}

// The import control needs a selected class year as well as the permission, and the class
// year defaults to 0, so assert on it only once a year is chosen.
async function mountPageWithClassYear() {
const wrapper = mountPage()
;(wrapper.vm as unknown as { classYear: { label: string; value: number } }).classYear = {
label: "Class of 2028",
value: 2028,
}
await nextTick()
return wrapper
}

describe("student class year permissions", () => {
it("offers delete and save to a user with SIS.AllStudents", async () => {
expect.hasAssertions()
signInWith([...STUDENTS_ONLY, SIS])

const wrapper = await mountPageWithDialogOpen()
const text = wrapper.text()

expect(text).toContain("Delete")
expect(text).toContain("Save")
})

it("hides delete and save from a user with only SVMSecure.Students", async () => {
expect.hasAssertions()
signInWith(STUDENTS_ONLY)

const wrapper = await mountPageWithDialogOpen()
const text = wrapper.text()

// Guard against the assertions passing just because the dialog never opened.
expect(text).toContain("Current class year")
expect(text).not.toContain("Delete")
expect(text).not.toContain("Save")
})

// Pairs with the test below: without this one, dropping the permission check from the
// import button would still leave the negative assertion passing on the class year alone.
it("offers the class year import link to a user with SIS.AllStudents", async () => {
expect.hasAssertions()
signInWith([...STUDENTS_ONLY, SIS])

const wrapper = await mountPageWithClassYear()

expect(wrapper.text()).toContain("Import students into")
})

it("hides the class year import link from a user without SIS.AllStudents", async () => {
expect.hasAssertions()
signInWith(STUDENTS_ONLY)

const wrapper = await mountPageWithClassYear()

expect(wrapper.text()).not.toContain("Import students into")
})

it("gates the class year import route on SIS.AllStudents", () => {
expect.hasAssertions()

const importRoute = routes.find((r) => r.path === "/Students/StudentClassYearImport")

expect(importRoute?.meta?.permissions).toStrictEqual([SIS])
})

it("sends the Razor ?import bookmark to the import route, keeping the class year", () => {
expect.hasAssertions()

expect(enterClassYearRoute({ import: "1", classYear: "2028" })).toStrictEqual({
path: "/Students/StudentClassYearImport",
query: { classYear: "2028" },
})
})

it("leaves the class year page alone when there is no import query", () => {
expect.hasAssertions()

expect(enterClassYearRoute({ classYear: "2028" })).toBeTruthy()
})
})
27 changes: 22 additions & 5 deletions VueApp/src/Students/pages/StudentClassYear.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, inject, watch } from "vue"
import { ref, inject, watch, computed } from "vue"
import { useRoute } from "vue-router"
import type { Ref } from "vue"
import type { QTableProps } from "quasar"
import { useFetch } from "@/composables/ViperFetch"
import { checkHasOnePermission } from "@/composables/CheckPagePermission"
import StatusBadge from "@/components/StatusBadge.vue"
import type {
StudentClassYear as StudentClassYearType,
Expand All @@ -17,6 +18,10 @@ const { get, put, del } = useFetch()
const apiUrl = inject("apiURL")
const viperUrl = inject("viperOneUrl")

// Every class-year mutation requires SVMSecure.SIS.AllStudents on the server, so the page stays
// readable without it but hides the controls that would 403 rather than reject them on click.
const canManageClassYears = computed(() => checkHasOnePermission(["SVMSecure.SIS.AllStudents"]))

//class year selection
const classYear = ref({ label: "", value: 0 })
const classYearOptions = ref([]) as Ref<ClassYear[]>
Expand Down Expand Up @@ -184,21 +189,26 @@ load()
v-close-popup
/>
</q-card-section>
<q-card-section class="q-pt-sm">
<q-card-section
class="q-pt-sm"
v-if="canManageClassYears"
>
If you change the class year from the current class year, a new record will be created and the
current class year will be marked as inactive with the reasons and term below.
</q-card-section>
<q-card-section>
<q-checkbox
v-model="studentClassYear.active"
label="Current class year"
:disable="!canManageClassYears"
></q-checkbox>
<br />
<q-checkbox
v-model="studentClassYear.ross"
label="Ross Student"
:disable="!canManageClassYears"
></q-checkbox>
<div>If changing class year, please fill out below.</div>
<div v-if="canManageClassYears">If changing class year, please fill out below.</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<q-select
outlined
dense
Expand All @@ -207,6 +217,7 @@ load()
v-model="studentClassYear.classYear"
emit-value
:options="classYearOptions"
:readonly="!canManageClassYears"
></q-select>
<q-select
outlined
Expand All @@ -218,6 +229,7 @@ load()
emit-value
map-options
:options="reasons"
:readonly="!canManageClassYears"
></q-select>
<q-select
outlined
Expand All @@ -229,16 +241,21 @@ load()
emit-value
map-options
:options="terms"
:readonly="!canManageClassYears"
></q-select>
<q-input
type="textarea"
outlined
dense
label="Comment"
v-model="studentClassYear.comment"
:readonly="!canManageClassYears"
></q-input>
</q-card-section>
<q-card-actions align="evenly">
<q-card-actions
align="evenly"
v-if="canManageClassYears"
>
<q-btn
no-caps
label="Save"
Expand Down Expand Up @@ -293,7 +310,7 @@ load()
<!-- eslint-disable harlanzw/vue-no-ref-access-in-templates -- classYear is { label, value }, not a ref .value access -->
<div class="col col-md-2 col-lg-2 offset-md-3">
<q-btn
v-if="classYear.value"
v-if="classYear.value && canManageClassYears"
:label="'Import students into ' + classYear?.label"
dense
no-caps
Expand Down
15 changes: 14 additions & 1 deletion VueApp/src/Students/router/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,19 @@ const routes = [
{
path: "/Students/StudentClassYear",
meta: { layout: ViperLayout },
// The Razor action served the import UI off this path via ?import, so keep old
// links and bookmarks working. Redirecting re-runs the guard, which applies the
// import route's SIS permission check.
beforeEnter: (to: RouteLocationNormalized) =>
to.query.import === undefined
? true
: { path: "/Students/StudentClassYearImport", query: { classYear: to.query.classYear } },
component: () => import("@/Students/pages/StudentClassYear.vue"),
},
{
// Importing is an SIS mutation end to end, so there is nothing here without the permission.
path: "/Students/StudentClassYearImport",
meta: { layout: ViperLayout },
meta: { layout: ViperLayout, permissions: ["SVMSecure.SIS.AllStudents"] },
component: () => import("@/Students/pages/StudentClassYearImport.vue"),
},
{
Expand Down Expand Up @@ -110,6 +118,11 @@ const routes = [
},
],
},
{
path: "/:catchAll(.*)*",
meta: { layout: ViperLayout },
component: () => import("@/pages/Error404.vue"),
},
]

export { routes, requireOwnStudentRecord, requireEditAccess }
75 changes: 0 additions & 75 deletions web/Areas/Students/Controllers/StudentsController.cs

This file was deleted.

3 changes: 0 additions & 3 deletions web/Areas/Students/Views/Index.cshtml

This file was deleted.

Loading
Loading