Skip to content
Open
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
18 changes: 9 additions & 9 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ jobs:
reuseaction:
name: REUSE Compliance Check
uses: StanfordBDHG/.github/.github/workflows/reuse.yml@v2
markdownlinkcheck:
name: Markdown Link Check
uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2.3
# markdownlinkcheck:
# name: Markdown Link Check
# uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2.3
Comment on lines 21 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Set explicit least-privilege permissions for reuseaction.

Line 83 permissions only scope buildandtest; reuseaction currently falls back to default GITHUB_TOKEN permissions, which can be broader than needed.

Suggested fix
   reuseaction:
     name: REUSE Compliance Check
+    permissions:
+      contents: read
     uses: StanfordBDHG/.github/.github/workflows/reuse.yml@v2
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
reuseaction:
name: REUSE Compliance Check
uses: StanfordBDHG/.github/.github/workflows/reuse.yml@v2
markdownlinkcheck:
name: Markdown Link Check
uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2.3
# markdownlinkcheck:
# name: Markdown Link Check
# uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2.3
reuseaction:
name: REUSE Compliance Check
permissions:
contents: read
uses: StanfordBDHG/.github/.github/workflows/reuse.yml@v2
# markdownlinkcheck:
# name: Markdown Link Check
# uses: StanfordBDHG/.github/.github/workflows/markdown-link-check.yml@v2.3
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 21-26: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)


[error] 23-23: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build-and-test.yml around lines 21 - 26, The reuseaction
job lacks explicit least-privilege permissions and currently relies on default
GITHUB_TOKEN permissions which may be broader than necessary. Add a permissions
block to the reuseaction job specifying only the minimum required permissions
needed for REUSE compliance checking, following the same principle as the
buildandtest job permissions defined on line 83.

Source: Linters/SAST tools

lint:
name: Lint
runs-on: ubuntu-latest
Expand Down Expand Up @@ -74,11 +74,11 @@ jobs:
- name: Test
id: test
run: npm run test:ci
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: functions/coverage/lcov.info
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
# - name: Upload coverage to Codecov
# uses: codecov/codecov-action@v5
# with:
# files: functions/coverage/lcov.info
# fail_ci_if_error: true
# token: ${{ secrets.CODECOV_TOKEN }}
permissions:
contents: read
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: 2023 Stanford University
# SPDX-License-Identifier: MIT

*.local.*
.DS_Store
credentials.json

Expand Down
5 changes: 4 additions & 1 deletion functions/models/src/functions/createInvitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
//

import { z } from "zod";
import { optionalish } from "../helpers/optionalish.js";
import { userAuthConverter } from "../types/userAuth.js";
import { userRegistrationConverter } from "../types/userRegistration.js";

export const createInvitationInputSchema = z.object({
auth: z.lazy(() => userAuthConverter.value.schema),
// Optional so that permanent (reusable) invitations can omit per-person auth.
auth: optionalish(z.lazy(() => userAuthConverter.value.schema)),
user: z.lazy(() => userRegistrationConverter.value.schema),
permanent: optionalish(z.boolean()),
});
export type CreateInvitationInput = z.input<typeof createInvitationInputSchema>;

Expand Down
5 changes: 5 additions & 0 deletions functions/models/src/types/invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ export const invitationConverter = new Lazy(
code: z.string(),
auth: optionalish(z.lazy(() => userAuthConverter.value.schema)),
user: z.lazy(() => userRegistrationConverter.value.schema),
permanent: optionalish(z.boolean()),
})
.transform((values) => new Invitation(values)),
encode: (object) => ({
code: object.code,
auth: object.auth ? userAuthConverter.value.encode(object.auth) : null,
user: userRegistrationConverter.value.encode(object.user),
permanent: object.permanent,
}),
}),
);
Expand All @@ -40,16 +42,19 @@ export class Invitation {
readonly code: string;
readonly auth?: UserAuth;
readonly user: UserRegistration;
readonly permanent: boolean;

// Constructor

constructor(input: {
code: string;
auth?: UserAuth;
user: UserRegistration;
permanent?: boolean;
}) {
this.code = input.code;
this.auth = input.auth;
this.user = input.user;
this.permanent = input.permanent ?? false;
}
}
62 changes: 61 additions & 1 deletion functions/src/functions/createInvitation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describeWithEmulators("function: createInvitation", (env) => {
expect(invitations.docs).toHaveLength(1);

const invitation = invitations.docs[0].data();
expect(invitation.code).toBe(input.auth.email);
expect(invitation.code).toBe(input.auth?.email);
});

it("should create an invitation for a patient", async () => {
Expand Down Expand Up @@ -77,6 +77,66 @@ describeWithEmulators("function: createInvitation", (env) => {
const invitation = invitations.docs[0].data();
expect(invitation.code).toHaveLength(8);
expect(invitation.code).toMatch(/^[A-Z0-9]{8}$/);
expect(invitation.permanent).toBe(false);
});

it("should create a permanent invitation", async () => {
const input: z.input<typeof createInvitationInputSchema> = {
auth: {
displayName: "Test User",
email: "engagehf-test@stanford.edu",
},
permanent: true,
user: {
type: UserType.patient,
organization: "stanford",
receivesAppointmentReminders: false,
receivesInactivityReminders: true,
receivesMedicationUpdates: true,
receivesQuestionnaireReminders: false,
receivesRecommendationUpdates: true,
receivesVitalsReminders: false,
receivesWeightAlerts: false,
},
};

await env.call(createInvitation, input, {
uid: "test",
token: { type: UserType.clinician, organization: "stanford" },
});

const invitations = await env.collections.invitations.get();
expect(invitations.docs).toHaveLength(1);
expect(invitations.docs[0].data().permanent).toBe(true);
});

it("should create a permanent invitation without auth", async () => {
const input: z.input<typeof createInvitationInputSchema> = {
permanent: true,
user: {
type: UserType.patient,
organization: "stanford",
receivesAppointmentReminders: false,
receivesInactivityReminders: true,
receivesMedicationUpdates: true,
receivesQuestionnaireReminders: false,
receivesRecommendationUpdates: true,
receivesVitalsReminders: false,
receivesWeightAlerts: false,
},
};

await env.call(createInvitation, input, {
uid: "test",
token: { type: UserType.clinician, organization: "stanford" },
});

const invitations = await env.collections.invitations.get();
expect(invitations.docs).toHaveLength(1);
const invitation = invitations.docs[0].data();
expect(invitation.permanent).toBe(true);
expect(invitation.auth).toBeUndefined();
expect(invitation.code).toMatch(/^[A-Z0-9]{8}$/);
});

it("should not allow clinician to create a clinician invitation", async () => {
Expand Down
2 changes: 1 addition & 1 deletion functions/src/functions/createInvitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export const createInvitation = validatedOnCall(

for (let counter = 0; ; counter++) {
const invitationCode =
isPatient ? generateInvitationCode(8) : request.data.auth.email;
isPatient ? generateInvitationCode(8) : request.data.auth?.email;
if (invitationCode === undefined)
throw new https.HttpsError(
"invalid-argument",
Expand Down
86 changes: 86 additions & 0 deletions functions/src/functions/enrollUser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,90 @@ describeWithEmulators("function: enrollUser", (env) => {
QuestionnaireReference.registration_en_US,
);
});

function patientInvitation(permanent: boolean): Invitation {
return new Invitation({
auth: new UserAuth({ email: "engagehf-test@stanford.edu" }),
code: "TESTCODE",
permanent,
user: new UserRegistration({
type: UserType.patient,
disabled: false,
selfManaged: false,
organization: "stanford",
receivesAppointmentReminders: true,
receivesInactivityReminders: true,
receivesMedicationUpdates: true,
receivesQuestionnaireReminders: true,
receivesRecommendationUpdates: true,
receivesVitalsReminders: true,
receivesWeightAlerts: true,
}),
});
}

async function enrollAndFinish(): Promise<string> {
const authUser = await env.auth.createUser({});
await env.call(
enrollUser,
{ invitationCode: "TESTCODE" },
{ uid: authUser.uid },
);
const userService = env.factory.user();
const dbUser = await userService.getUser(authUser.uid);
expect(dbUser).toBeDefined();
if (dbUser !== undefined) await userService.finishUserEnrollment(dbUser);
return authUser.uid;
}

it("allows a permanent invitation to be reused by multiple users", async () => {
const invitationRef = env.collections.invitations.doc();
await invitationRef.set(patientInvitation(true));

const seededAppointment = new FHIRAppointment({
status: FHIRAppointmentStatus.booked,
created: new Date("2023-12-24"),
start: new Date("2023-12-31"),
end: new Date("2024-01-01"),
participant: [],
});
await env.collections
.invitationAppointments(invitationRef.id)
.doc()
.set(seededAppointment);

// First enrollee receives the seeded data and the invitation survives.
const firstUserId = await enrollAndFinish();

const invitationsAfterFirst = await env.collections.invitations.get();
expect(invitationsAfterFirst.docs).toHaveLength(1);
const remainingAppointments = await env.collections
.invitationAppointments(invitationRef.id)
.get();
expect(remainingAppointments.docs).toHaveLength(1);
const firstUserAppointments = await env.collections
.userAppointments(firstUserId)
.get();
expect(firstUserAppointments.docs).toHaveLength(1);

// Second enrollee can reuse the same code and also receives the seeded data.
const secondUserId = await enrollAndFinish();

const invitationsAfterSecond = await env.collections.invitations.get();
expect(invitationsAfterSecond.docs).toHaveLength(1);
const secondUserAppointments = await env.collections
.userAppointments(secondUserId)
.get();
expect(secondUserAppointments.docs).toHaveLength(1);
});

it("deletes a non-permanent invitation after enrollment", async () => {
const invitationRef = env.collections.invitations.doc();
await invitationRef.set(patientInvitation(false));

await enrollAndFinish();

const invitations = await env.collections.invitations.get();
expect(invitations.docs).toHaveLength(0);
});
});
Loading
Loading