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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,22 @@ An exceptionGroup list containing groups whose users should be excluded from val

An optional --disable-users CLI flag that, when passed, will disable users identified as invalid due to missing or disabled 2FA.

An optional --filtered-by-month (-f) CLI flag that, when passed, restricts disable candidates to users whose account was created at least `disableAfterMonths` months ago (configured in the datastore).

An optional --created-date-overrides-file CLI flag that accepts a path to a JSON file. For each user listed in the file, their real DHIS2 creation date is replaced by the `createdDate` field in the file when evaluating the month-based filter (`--filtered-by-month`). This allows treating specific users as if they were created on a given date. **Only has effect when --filtered-by-month is also set.**

The overrides file format:

```json
{
"createdDate": "2026-06-10",
"users": [
{ "id": "uid1" },
{ "id": "uid2", "username": "pepito" }
]
}
```

The datastore must contain:

```json
Expand Down
5 changes: 5 additions & 0 deletions src/domain/entities/DateTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ export function getMonthsDiff(startDate: string, endDate: string): number {
const diff = end.diff(start, "months").months;
return Math.floor(diff);
}

/* Return true if the given string is a valid ISO 8601 date/datetime (as parsed by luxon). */
export function isValidIso8601(date: string): boolean {
return DateTime.fromISO(date).isValid;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface TwoFactorUser {
created: Timestamp;
}

export interface UserDateOverride {
createdDate: string;
users: Array<{ id: string; username?: string }>;
}

export function filterByCreationDate(
users: TwoFactorUser[],
disableAfterMonths: number | undefined
Expand All @@ -28,3 +33,14 @@ export function filterByCreationDate(
return monthsDiff >= disableAfterMonths;
});
}

export function applyUserDateOverrides(
users: TwoFactorUser[],
overrides: UserDateOverride | undefined
): TwoFactorUser[] {
if (!overrides) return users;
const overrideIds = new Set(overrides.users.map(u => u.id));
return users.map(user =>
overrideIds.has(user.id) ? { ...user, created: overrides.createdDate } : user
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { TwoFactorReportRepository } from "domain/repositories/user-monitoring/t
import { TwoFactorConfigRepository } from "domain/repositories/user-monitoring/two-factor-monitoring/TwoFactorConfigRepository";
import { UserMonitoringProgramRepository } from "domain/repositories/user-monitoring/common/UserMonitoringProgramRepository";
import {
TwoFactorUser,
UserDateOverride,
applyUserDateOverrides,
filterByCreationDate,
} from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser";

Expand Down Expand Up @@ -82,7 +83,10 @@ export class RunTwoFactorReportUseCase {
});

const filteredInvalidTwoFactorUsers = filteredByMonth
? filterByCreationDate(invalidTwoFactorUsers, options.disableAfterMonths)
? filterByCreationDate(
applyUserDateOverrides(invalidTwoFactorUsers, twoFactorUseCaseOption.userDateOverrides),
options.disableAfterMonths
)
: invalidTwoFactorUsers;

const report: TwoFactorUserReport = {
Expand Down Expand Up @@ -146,4 +150,5 @@ export class RunTwoFactorReportUseCase {
interface TwoFactorUseCaseOptions {
shouldDisableInvalidUsers: boolean;
filteredByMonth: boolean;
userDateOverrides?: UserDateOverride;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import {
import { TwoFactorUserD2Repository } from "data/user-monitoring/two-factor-monitoring/TwoFactorUserD2Repository";
import { TwoFactorReportD2Repository } from "data/user-monitoring/two-factor-monitoring/TwoFactorReportD2Repository";
import { UserMonitoringProgramD2Repository } from "data/user-monitoring/common/UserMonitoringProgramD2Repository";
import { TwoFactorUser, filterByCreationDate } from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser";
import {
TwoFactorUser,
UserDateOverride,
applyUserDateOverrides,
filterByCreationDate,
} from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUser";
import { TwoFactorUserOptions } from "domain/entities/user-monitoring/two-factor-monitoring/TwoFactorUserOptions";
const TWO_FACTOR_GROUP_ID = "2FA";
const WHO_ACCOUNT_GROUP_ID = "WHO";
Expand Down Expand Up @@ -417,6 +422,107 @@ describe("TwoFactorReportUseCase", () => {
});
});

describe("TwoFactorReportUseCase with userDateOverrides", () => {
const now = new Date();
const oldDate = new Date(now.getFullYear() - 1, now.getMonth(), 1).toISOString();
const recentDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();

const configWithMonths: TwoFactorUserOptions = { ...defaultConfig, disableAfterMonths: 6 };

function makeInvalid2FAUser(id: string, created: string): TwoFactorUser {
return {
...baseUser,
id,
username: id,
created,
userGroups: [{ id: TWO_FACTOR_GROUP_ID, name: "TwoFactorGroup" }],
};
}

it("Should protect an overridden user with a recent JSON date from being disabled", async () => {
const oldUser = makeInvalid2FAUser("old-user", oldDate);
const overrides: UserDateOverride = {
createdDate: recentDate,
users: [{ id: "old-user", username: "old-user" }],
};
const useCase = createUseCase({ users: [oldUser], config: configWithMonths });

const result = await useCase.execute({
shouldDisableInvalidUsers: true,
filteredByMonth: true,
userDateOverrides: overrides,
});

expect(result.report.invalidTwoFAList).toEqual([]);
expect(result.disableUsersMessage).contain("no invalid users found");
});

it("Should still disable an overridden user whose JSON date is old enough", async () => {
const recentUser = makeInvalid2FAUser("recent-user", recentDate);
const overrides: UserDateOverride = {
createdDate: oldDate,
users: [{ id: "recent-user", username: "recent-user" }],
};
const useCase = createUseCase({ users: [recentUser], config: configWithMonths });

const result = await useCase.execute({
shouldDisableInvalidUsers: true,
filteredByMonth: true,
userDateOverrides: overrides,
});

expect(result.report.invalidTwoFAList).toEqual([{ id: "recent-user", name: "recent-user" }]);
expect(result.disableUsersMessage).contain("Disabled users action is enabled and executed.");
});

it("Should not apply overrides when filteredByMonth is false (real date is used)", async () => {
const recentUser = makeInvalid2FAUser("recent-user", recentDate);
const overrides: UserDateOverride = {
createdDate: oldDate,
users: [{ id: "recent-user", username: "recent-user" }],
};
const useCase = createUseCase({ users: [recentUser], config: configWithMonths });

const result = await useCase.execute({
shouldDisableInvalidUsers: false,
filteredByMonth: false,
userDateOverrides: overrides,
});

expect(result.report.invalidTwoFAList).toEqual([{ id: "recent-user", name: "recent-user" }]);
});

it("Should not affect users whose id is not in the overrides list", async () => {
const oldUser = makeInvalid2FAUser("old-user", oldDate);
const overrides: UserDateOverride = {
createdDate: recentDate,
users: [{ id: "different-user", username: "different-user" }],
};
const useCase = createUseCase({ users: [oldUser], config: configWithMonths });

const result = await useCase.execute({
shouldDisableInvalidUsers: false,
filteredByMonth: true,
userDateOverrides: overrides,
});

expect(result.report.invalidTwoFAList).toEqual([{ id: "old-user", name: "old-user" }]);
});

it("Should behave identically to no override when userDateOverrides is undefined", async () => {
const oldUser = makeInvalid2FAUser("old-user", oldDate);
const useCase = createUseCase({ users: [oldUser], config: configWithMonths });

const result = await useCase.execute({
shouldDisableInvalidUsers: false,
filteredByMonth: true,
userDateOverrides: undefined,
});

expect(result.report.invalidTwoFAList).toEqual([{ id: "old-user", name: "old-user" }]);
});
});

describe("filterByCreationDate", () => {
const makeUser = (created: string): TwoFactorUser => ({
...baseUser,
Expand Down Expand Up @@ -458,14 +564,61 @@ describe("filterByCreationDate", () => {
});
});

describe("applyUserDateOverrides", () => {
const baseUser2FA: TwoFactorUser = {
...baseUser,
id: "u1",
userGroups: [{ id: TWO_FACTOR_GROUP_ID, name: "" }],
created: "2020-01-01T00:00:00.000",
};

it("Should return users unchanged when overrides is undefined", () => {
const result = applyUserDateOverrides([baseUser2FA], undefined);
expect(result).toEqual([baseUser2FA]);
});

it("Should replace created date for a user in the overrides list", () => {
const overrides: UserDateOverride = {
createdDate: "2026-06-10",
users: [{ id: "u1", username: "testuser" }],
};
const result = applyUserDateOverrides([baseUser2FA], overrides);
expect(result[0]?.created).toBe("2026-06-10");
});

it("Should not modify a user whose id is not in the overrides list", () => {
const overrides: UserDateOverride = {
createdDate: "2026-06-10",
users: [{ id: "other-id", username: "other" }],
};
const result = applyUserDateOverrides([baseUser2FA], overrides);
expect(result[0]?.created).toBe("2020-01-01T00:00:00.000");
});

it("Should handle an empty overrides user list without modifying any user", () => {
const overrides: UserDateOverride = { createdDate: "2026-06-10", users: [] };
const result = applyUserDateOverrides([baseUser2FA], overrides);
expect(result[0]?.created).toBe("2020-01-01T00:00:00.000");
});

it("Should apply override only to matching user when multiple users are present", () => {
const user2: TwoFactorUser = { ...baseUser2FA, id: "u2", created: "2019-01-01T00:00:00.000" };
const overrides: UserDateOverride = {
createdDate: "2026-06-10",
users: [{ id: "u1", username: "testuser" }],
};
const result = applyUserDateOverrides([baseUser2FA, user2], overrides);
expect(result[0]?.created).toBe("2026-06-10");
expect(result[1]?.created).toBe("2019-01-01T00:00:00.000");
});
});

function createUseCase({
users,
config = alternativeConfig,
}: {
users: TwoFactorUser[];
config?: TwoFactorUserOptions;
programId?: string;
programName?: string;
}): RunTwoFactorReportUseCase {
const excludedGroupIds = config.exceptionGroup?.map(g => g.id) ?? [];

Expand Down
64 changes: 64 additions & 0 deletions src/scripts/commands/__tests__/userMonitoring.specs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { parseUserDateOverridesFile } from "../userMonitoring";

describe("parseUserDateOverridesFile", () => {
let tmpFile: string;

beforeEach(() => {
tmpFile = path.join(os.tmpdir(), `test-overrides-${Date.now()}.json`);
});

afterEach(() => {
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
});

it("Should parse a valid overrides file", () => {
fs.writeFileSync(
tmpFile,
JSON.stringify({ createdDate: "2026-06-10", users: [{ id: "uid1" }, { id: "uid2", username: "pepito" }] })
);
const result = parseUserDateOverridesFile(tmpFile);
expect(result.createdDate).toBe("2026-06-10");
expect(result.users).toHaveLength(2);
});

it("Should throw if the file does not contain valid JSON", () => {
fs.writeFileSync(tmpFile, "this is not json {{{");
expect(() => parseUserDateOverridesFile(tmpFile)).toThrow(
`Could not parse ${tmpFile} as JSON. Make sure the file contains valid JSON.`
);
});

it("Should throw if createdDate field is missing", () => {
fs.writeFileSync(tmpFile, JSON.stringify({ users: [{ id: "uid1" }] }));
expect(() => parseUserDateOverridesFile(tmpFile)).toThrow(`Invalid format in ${tmpFile}`);
});

it("Should throw if users field is missing", () => {
fs.writeFileSync(tmpFile, JSON.stringify({ createdDate: "2026-06-10" }));
expect(() => parseUserDateOverridesFile(tmpFile)).toThrow(`Invalid format in ${tmpFile}`);
});

it("Should throw if createdDate is not a valid date", () => {
fs.writeFileSync(tmpFile, JSON.stringify({ createdDate: "not-a-date", users: [] }));
expect(() => parseUserDateOverridesFile(tmpFile)).toThrow(
`Invalid createdDate "not-a-date" in ${tmpFile}: must be a valid ISO date (e.g. "2026-06-10").`
);
});

it("Should throw if createdDate is a non-ISO date format (e.g. DD/MM/YYYY)", () => {
fs.writeFileSync(tmpFile, JSON.stringify({ createdDate: "10/06/2026", users: [] }));
expect(() => parseUserDateOverridesFile(tmpFile)).toThrow(
`Invalid createdDate "10/06/2026" in ${tmpFile}: must be a valid ISO date (e.g. "2026-06-10").`
);
});

it("Should throw if the file does not exist", () => {
expect(() => parseUserDateOverridesFile("/nonexistent/path/file.json")).toThrow(
"Could not parse /nonexistent/path/file.json as JSON. Make sure the file contains valid JSON."
);
});
});
Loading
Loading