mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
The SETTING verdicts are now the keys of the codec table, so a column becomes a setting by getting a codec and nothing else has to name it. The verdicts for every other User column move beside it into account-settings-classes.ts, and the restore half into account-settings-restore.ts. The backup plan keeps the model verdicts. The classification guard now also refuses a column that has both a codec and another verdict, since the composed table would quietly carry it as a setting.
102 lines
4.3 KiB
TypeScript
102 lines
4.3 KiB
TypeScript
/**
|
|
* Every `User` column has a backup verdict, read from the schema rather than
|
|
* from a list somebody keeps.
|
|
*
|
|
* The account row mixes the person's choices with sign-in credentials,
|
|
* provider keys, the operator's policy for the account and the host's own
|
|
* cursors, so it cannot have one verdict the way a model does. A column added
|
|
* tomorrow gets a verdict in the account-settings domain — a codec in
|
|
* `src/lib/export/account-settings-backup.ts` makes it a setting, an entry in
|
|
* `src/lib/export/account-settings-classes.ts` gives it any other verdict —
|
|
* or this fails naming it: a new preference that silently
|
|
* stays behind on a restore, or a new secret that silently rides along in
|
|
* every backup file, are the two failures this exists to stop.
|
|
*
|
|
* Mutation check: delete any key from the table and the first case goes red
|
|
* naming the column; add a `User` column to the schema without classifying it
|
|
* and the same case goes red naming that one.
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape";
|
|
import {
|
|
ACCOUNT_SETTING_COLUMNS,
|
|
USER_COLUMN_BACKUP_CLASS,
|
|
} from "@/lib/export/account-settings-backup";
|
|
import { USER_COLUMNS_OUTSIDE_SETTINGS } from "@/lib/export/account-settings-classes";
|
|
|
|
const columns = parseWipeSchema().get("User")?.scalars ?? [];
|
|
const classified = USER_COLUMN_BACKUP_CLASS as Readonly<Record<string, string>>;
|
|
|
|
describe("User column backup classification", () => {
|
|
it("classifies every User column", () => {
|
|
// A floor, so a parser that stops matching cannot pass on an empty list.
|
|
expect(columns.length).toBeGreaterThan(100);
|
|
const unclassified = columns.filter((c) => !Object.hasOwn(classified, c));
|
|
expect(
|
|
unclassified,
|
|
"these User columns have no backup verdict. Add each to " +
|
|
"account-settings-classes.ts as CREDENTIAL, IDENTITY or " +
|
|
"OPERATIONAL, or give it a codec in account-settings-backup.ts, " +
|
|
"which makes it a SETTING",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("names no column the schema no longer has", () => {
|
|
const known = new Set(columns);
|
|
expect(Object.keys(classified).filter((c) => !known.has(c))).toEqual([]);
|
|
});
|
|
|
|
it("gives no column both a codec and another verdict", () => {
|
|
// The composed table spreads the settings over the other verdicts, so a
|
|
// column in both would silently travel as a setting.
|
|
expect(ACCOUNT_SETTING_COLUMNS.length).toBeGreaterThan(50);
|
|
expect(
|
|
ACCOUNT_SETTING_COLUMNS.filter((column) =>
|
|
Object.hasOwn(USER_COLUMNS_OUTSIDE_SETTINGS, column),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("carries exactly the columns classified as settings", () => {
|
|
expect([...ACCOUNT_SETTING_COLUMNS].sort()).toEqual(
|
|
Object.entries(classified)
|
|
.filter(([, verdict]) => verdict === "SETTING")
|
|
.map(([column]) => column)
|
|
.sort(),
|
|
);
|
|
});
|
|
|
|
// The person's own data, sealed at rest for privacy rather than a secret
|
|
// that grants anything: the insurance number, and since v1.42 the home
|
|
// location the environment module resolves weather against.
|
|
const SEALED_SETTINGS = ["insuranceNumberEncrypted", "homeLocationEncrypted"];
|
|
|
|
it("carries no credential-shaped column as a setting", () => {
|
|
const sealedSettings = ACCOUNT_SETTING_COLUMNS.filter((column) =>
|
|
/Encrypted$|Token|Secret|password|totp|oidc/i.test(column),
|
|
);
|
|
expect(sealedSettings.sort()).toEqual([...SEALED_SETTINGS].sort());
|
|
});
|
|
|
|
// A cache this host wrote, sealed because its content is model prose about
|
|
// the person: OPERATIONAL like its readable predecessor, and like every
|
|
// other class but SETTING it never enters the file. Named here so a sealed
|
|
// column cannot drift out of CREDENTIAL without someone saying why.
|
|
const SEALED_OPERATIONAL = ["insightsCachedTextEncrypted"];
|
|
|
|
it("never classifies a sealed column other than the sealed settings as anything but a credential", () => {
|
|
const sealed = columns.filter(
|
|
(c) =>
|
|
/Encrypted$/.test(c) &&
|
|
!SEALED_SETTINGS.includes(c) &&
|
|
!SEALED_OPERATIONAL.includes(c),
|
|
);
|
|
expect(sealed.length).toBeGreaterThan(20);
|
|
expect(sealed.filter((c) => classified[c] !== "CREDENTIAL")).toEqual([]);
|
|
for (const c of SEALED_OPERATIONAL) {
|
|
expect(classified[c]).toBe("OPERATIONAL");
|
|
}
|
|
});
|
|
});
|