mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge remote-tracking branch 'origin/work/side-effect-backup' into work/sharing-write-integration
This commit is contained in:
commit
7eed26eb01
10 files changed
+297
-12
No files matched your search
@@ -2,10 +2,11 @@
|
||||
* POST /api/admin/backups/[id]/restore — admin-only disaster recovery.
|
||||
*
|
||||
* After owner and schema validation, one transaction replaces every
|
||||
* serialized owner-scoped class: measurements, medication history, mood and
|
||||
* rated factors, cycle data, labs/biomarkers, illness history, allergies,
|
||||
* family history, workout summaries, and inbound documents. Document content
|
||||
* and summary ciphertext are decoded from base64 and persisted verbatim.
|
||||
* serialized owner-scoped class: measurements, medication history including the
|
||||
* side effects recorded against a drug, mood and rated factors, cycle data,
|
||||
* labs/biomarkers, illness history, allergies, family history, workout
|
||||
* summaries, and inbound documents. Document content and summary ciphertext are
|
||||
* decoded from base64 and persisted verbatim.
|
||||
*
|
||||
* Metadata-only portable document exports are rejected before mutation; the
|
||||
* importer never fabricates content. Audit rows remain outside the wipe, and
|
||||
@@ -584,6 +585,32 @@ const handler = apiHandler(
|
||||
doseWindows: (s.doseWindows ?? null) as never,
|
||||
})),
|
||||
},
|
||||
// Written in the same `create` as the drug, so the FK binds to
|
||||
// the id this row actually got. A canonical DR file preserves
|
||||
// the medication's id and a portable one mints a fresh cuid;
|
||||
// neither case needs the old `medicationId` from the file,
|
||||
// which is why the payload does not carry it. The note follows
|
||||
// the measurement contract next to it: ciphertext verbatim when
|
||||
// the file has it, legacy plaintext encrypted on the way in,
|
||||
// and the plaintext column left null either way.
|
||||
sideEffects: {
|
||||
create: m.sideEffects.map((s) => ({
|
||||
...(s.id ? { id: s.id } : {}),
|
||||
userId: ownerId,
|
||||
occurredAt: new Date(s.occurredAt),
|
||||
category: s.category,
|
||||
entry: s.entry,
|
||||
severity: s.severity,
|
||||
notes: null,
|
||||
notesEncrypted:
|
||||
s.notesEncrypted == null
|
||||
? encryptNote(s.notes ?? null)
|
||||
: decodeEncryptedBytes(s.notesEncrypted),
|
||||
...(s.createdAt
|
||||
? { createdAt: new Date(s.createdAt) }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
restoredMedicationIds.add(created.id);
|
||||
|
||||
@@ -105,6 +105,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_medication_side_effect_count: counts.medicationSideEffects,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
|
||||
@@ -64,6 +64,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
export_measurements_count: counts.measurements,
|
||||
export_medications_count: counts.medications,
|
||||
export_intake_count: counts.intakeEvents,
|
||||
export_medication_side_effect_count: counts.medicationSideEffects,
|
||||
export_mood_count: counts.moodEntries,
|
||||
export_cycle_count: counts.cycles,
|
||||
export_cycle_day_log_count: counts.cycleDayLogs,
|
||||
|
||||
@@ -324,6 +324,9 @@ export function BackupsSection() {
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
moodEntries: number;
|
||||
// Optional for the same reason as the keys below: a file written
|
||||
// before side effects rode the wire carries no key at all.
|
||||
medicationSideEffects?: number;
|
||||
cycles?: number;
|
||||
cycleDayLogs?: number;
|
||||
// v1.28 backup-completeness — optional so an older-schema upload
|
||||
@@ -354,6 +357,7 @@ export function BackupsSection() {
|
||||
data.summary.medications +
|
||||
data.summary.intakeEvents +
|
||||
data.summary.moodEntries +
|
||||
(data.summary.medicationSideEffects ?? 0) +
|
||||
(data.summary.cycles ?? 0) +
|
||||
(data.summary.cycleDayLogs ?? 0) +
|
||||
(data.summary.labResults ?? 0) +
|
||||
|
||||
@@ -37,6 +37,7 @@ import { restoreProfileData } from "../profile-backup";
|
||||
|
||||
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
|
||||
const measurementNote = encryptToBytes("canonical measurement note");
|
||||
const sideEffectNote = encryptToBytes("nausea after the evening dose");
|
||||
|
||||
const appSettings = {
|
||||
id: "singleton",
|
||||
@@ -133,6 +134,29 @@ function makePrisma() {
|
||||
],
|
||||
},
|
||||
],
|
||||
sideEffects: [
|
||||
{
|
||||
id: "side-effect-canonical",
|
||||
occurredAt: new Date("2026-07-18T21:00:00.000Z"),
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 3,
|
||||
notes: null,
|
||||
notesEncrypted: sideEffectNote,
|
||||
createdAt: new Date("2026-07-18T21:05:00.000Z"),
|
||||
},
|
||||
{
|
||||
// A row the note backfill has not reached: plaintext only.
|
||||
id: "side-effect-legacy",
|
||||
occurredAt: new Date("2026-07-17T21:00:00.000Z"),
|
||||
category: "COGNITIVE",
|
||||
entry: "DIZZINESS",
|
||||
severity: 1,
|
||||
notes: "legacy plaintext note",
|
||||
notesEncrypted: null,
|
||||
createdAt: new Date("2026-07-17T21:05:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
@@ -492,6 +516,90 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Side effects ride inside their medication, and the note rides with them.
|
||||
*
|
||||
* The two shapes differ on purpose: a portable export is the human-readable
|
||||
* artefact, so it carries the DECRYPTED note and no ciphertext at all; the
|
||||
* disaster-recovery file carries the ciphertext verbatim so the same instance's
|
||||
* key reads it back unchanged. Both are asserted, because emitting ciphertext
|
||||
* into a portable export and losing the note are opposite failures of the same
|
||||
* decision.
|
||||
*/
|
||||
describe("buildFullBackupPayload — medication side effects", () => {
|
||||
it("decrypts the note and emits no ciphertext in a portable export", async () => {
|
||||
installSectionMocks();
|
||||
|
||||
const { payload, counts } = await buildFullBackupPayload(
|
||||
makePrisma() as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "portable-export",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
const parsed = parseBackupPayload(payload);
|
||||
|
||||
expect(parsed.medications[0].sideEffects).toEqual([
|
||||
{
|
||||
occurredAt: "2026-07-18T21:00:00.000Z",
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 3,
|
||||
notes: "nausea after the evening dose",
|
||||
},
|
||||
{
|
||||
occurredAt: "2026-07-17T21:00:00.000Z",
|
||||
category: "COGNITIVE",
|
||||
entry: "DIZZINESS",
|
||||
severity: 1,
|
||||
notes: "legacy plaintext note",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(payload)).not.toContain(
|
||||
Buffer.from(sideEffectNote).toString("base64"),
|
||||
);
|
||||
expect(counts.medicationSideEffects).toBe(2);
|
||||
});
|
||||
|
||||
it("carries the ciphertext verbatim in a disaster-recovery payload", async () => {
|
||||
installSectionMocks();
|
||||
|
||||
const { payload } = await buildFullBackupPayload(
|
||||
makePrisma() as never,
|
||||
"user-1",
|
||||
{
|
||||
purpose: "disaster-recovery",
|
||||
exportedAt: new Date("2026-07-20T00:00:00.000Z"),
|
||||
},
|
||||
);
|
||||
const parsed = parseBackupPayload(payload);
|
||||
|
||||
expect(parsed.medications[0].sideEffects).toEqual([
|
||||
{
|
||||
id: "side-effect-canonical",
|
||||
occurredAt: "2026-07-18T21:00:00.000Z",
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 3,
|
||||
notes: null,
|
||||
notesEncrypted: Buffer.from(sideEffectNote).toString("base64"),
|
||||
createdAt: "2026-07-18T21:05:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "side-effect-legacy",
|
||||
occurredAt: "2026-07-17T21:00:00.000Z",
|
||||
category: "COGNITIVE",
|
||||
entry: "DIZZINESS",
|
||||
severity: 1,
|
||||
notes: "legacy plaintext note",
|
||||
notesEncrypted: null,
|
||||
createdAt: "2026-07-17T21:05:00.000Z",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `aggregationProvenance` is what stops an export.xml source-day estimate from
|
||||
* overwriting a native HealthKit statistic. A disaster-recovery backup that
|
||||
|
||||
@@ -210,6 +210,7 @@ export const TWO_ENDED_MODELS = [
|
||||
"Medication",
|
||||
"MedicationSchedule",
|
||||
"MedicationIntakeEvent",
|
||||
"MedicationSideEffect",
|
||||
"MoodEntry",
|
||||
"MoodEntryTagLink",
|
||||
"MoodTag",
|
||||
@@ -276,8 +277,6 @@ export const COVERAGE_PENDING: Readonly<Record<string, string>> = {
|
||||
"Titration history — when a dose went up or down and by how much. A restore rebuilds the current dose and drops the ramp that led to it.",
|
||||
MedicationPauseEra:
|
||||
"The spans where a medication was deliberately paused. Without them the compliance recomputation counts a deliberate pause as missed doses and the restored account looks non-adherent.",
|
||||
MedicationSideEffect:
|
||||
"Side effects the person recorded against a drug. Restoring the drug without them loses the reason someone may have stopped taking it.",
|
||||
MedicationEfficacyTarget:
|
||||
"What a medication was supposed to move, and by how much. The drug comes back with no statement of what it was for.",
|
||||
MedicationInventoryItem:
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface FullBackupCounts
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
medicationSideEffects: number;
|
||||
moodEntries: number;
|
||||
cycles: number;
|
||||
cycleDayLogs: number;
|
||||
@@ -186,7 +187,17 @@ export async function buildFullBackupPayload(
|
||||
})(),
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
include: { schedules: true },
|
||||
// Side effects ride INSIDE their medication, exactly as the schedules
|
||||
// beside them do. The alternative — a top-level array carrying
|
||||
// `medicationId` — would have to survive a restore that mints new
|
||||
// medication ids for a portable file, which is the id-remap the intake
|
||||
// events already have to work around by drug name. A nested create has
|
||||
// no id to remap: Prisma binds the child to whatever id the parent row
|
||||
// actually got.
|
||||
include: {
|
||||
schedules: true,
|
||||
sideEffects: { orderBy: { occurredAt: "desc" } },
|
||||
},
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
|
||||
@@ -336,6 +347,28 @@ export async function buildFullBackupPayload(
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
})),
|
||||
// What the person recorded against the drug, and the reason a drug may
|
||||
// have been stopped. `notes` is the same dual-column arrangement
|
||||
// `Measurement` has and gets the same treatment: a portable export
|
||||
// carries the DECRYPTED note and no ciphertext, a disaster-recovery
|
||||
// payload carries the ciphertext verbatim plus whatever legacy plaintext
|
||||
// the row still holds, and the restore re-encrypts the plaintext case.
|
||||
sideEffects: m.sideEffects.map((s) => ({
|
||||
...(disasterRecovery
|
||||
? {
|
||||
id: s.id,
|
||||
notes: s.notes,
|
||||
notesEncrypted: s.notesEncrypted
|
||||
? Buffer.from(s.notesEncrypted).toString("base64")
|
||||
: null,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
}
|
||||
: { notes: readNote(s.notesEncrypted, s.notes) }),
|
||||
occurredAt: s.occurredAt.toISOString(),
|
||||
category: s.category,
|
||||
entry: s.entry,
|
||||
severity: s.severity,
|
||||
})),
|
||||
})),
|
||||
intakeEvents: intakeEvents.map((e) => ({
|
||||
...(disasterRecovery
|
||||
@@ -428,6 +461,10 @@ export async function buildFullBackupPayload(
|
||||
measurements: measurements.length,
|
||||
medications: medications.length,
|
||||
intakeEvents: intakeEvents.length,
|
||||
medicationSideEffects: medications.reduce(
|
||||
(total, m) => total + m.sideEffects.length,
|
||||
0,
|
||||
),
|
||||
moodEntries: moodEntries.length,
|
||||
cycles: cycle.cycles.length,
|
||||
cycleDayLogs: cycle.cycleDayLogs.length,
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
MedicationCategory,
|
||||
MedicationDeliveryForm,
|
||||
MedicationScheduleType,
|
||||
MedicationSideEffectCategory,
|
||||
MedicationSideEffectEntry,
|
||||
OvulationTest,
|
||||
RhythmClassification,
|
||||
SecondarySymptom,
|
||||
@@ -129,6 +131,28 @@ const medicationScheduleSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* One recorded side effect, carried inside its medication.
|
||||
*
|
||||
* `notes` is the decrypted note in a portable export and the row's legacy
|
||||
* plaintext column in a canonical DR file; `notesEncrypted` is the base64
|
||||
* ciphertext and rides only in the DR case. The restore prefers the ciphertext
|
||||
* and encrypts the plaintext when that is all the file has, so neither shape
|
||||
* loses the note and neither writes plaintext back into the column.
|
||||
*/
|
||||
const medicationSideEffectSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
occurredAt: isoDateTime,
|
||||
category: z.enum(MedicationSideEffectCategory),
|
||||
entry: z.enum(MedicationSideEffectEntry),
|
||||
severity: z.number().int().min(1).max(5),
|
||||
notes: z.string().nullable().optional(),
|
||||
notesEncrypted: base64BytesSchema.nullable().optional(),
|
||||
createdAt: isoDateTime.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const medicationSchema = z
|
||||
.object({
|
||||
id: z.string().min(1).optional(),
|
||||
@@ -160,6 +184,9 @@ const medicationSchema = z
|
||||
createdAt: isoDateTime.optional(),
|
||||
updatedAt: isoDateTime.optional(),
|
||||
schedules: z.array(medicationScheduleSchema).default([]),
|
||||
// Defaulted so a file written before side effects rode the wire still
|
||||
// parses, and a drug with none writes [].
|
||||
sideEffects: z.array(medicationSideEffectSchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -907,6 +934,8 @@ export interface BackupSummary {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
/** Side effects recorded against a drug, across every medication. */
|
||||
medicationSideEffects: number;
|
||||
moodEntries: number;
|
||||
/** v1.15.0 — observed cycle spans in the backup. */
|
||||
cycles: number;
|
||||
@@ -953,6 +982,10 @@ export function summarizeBackup(payload: BackupPayload): BackupSummary {
|
||||
measurements: payload.measurements.length,
|
||||
medications: payload.medications.length,
|
||||
intakeEvents: payload.intakeEvents.length,
|
||||
medicationSideEffects: payload.medications.reduce(
|
||||
(sum, medication) => sum + medication.sideEffects.length,
|
||||
0,
|
||||
),
|
||||
moodEntries: payload.moodEntries.length,
|
||||
cycles: payload.cycles.length,
|
||||
cycleDayLogs: payload.cycleDayLogs.length,
|
||||
|
||||
@@ -284,6 +284,19 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
},
|
||||
include: { schedules: true },
|
||||
});
|
||||
const sideEffect = await prisma.medicationSideEffect.create({
|
||||
data: {
|
||||
id: "side-effect-dr",
|
||||
userId: ownerId,
|
||||
medicationId: medication.id,
|
||||
occurredAt: new Date("2026-06-27T21:00:00.000Z"),
|
||||
category: "INJECTION_SITE",
|
||||
entry: "INJECTION_SWELLING",
|
||||
severity: 4,
|
||||
notesEncrypted: encryptToBytes("swollen for a day after the injection"),
|
||||
createdAt: new Date("2026-06-27T21:10:00.000Z"),
|
||||
},
|
||||
});
|
||||
const intake = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
id: "intake-dr",
|
||||
@@ -583,6 +596,15 @@ describe("canonical disaster-recovery backup round-trip", () => {
|
||||
where: { id: intake.id },
|
||||
}),
|
||||
).toEqual(intake);
|
||||
// Byte-for-byte, ciphertext included: a canonical payload carries the
|
||||
// encrypted note verbatim, so the same instance's key reads back exactly
|
||||
// what it wrote. Re-encrypting here would still decrypt to the same words
|
||||
// and would still be a different row than the one the file described.
|
||||
expect(
|
||||
await prisma.medicationSideEffect.findUniqueOrThrow({
|
||||
where: { id: sideEffect.id },
|
||||
}),
|
||||
).toEqual(sideEffect);
|
||||
expect(
|
||||
await prisma.cycleProfile.findUniqueOrThrow({
|
||||
where: { id: cycleProfile.id },
|
||||
|
||||
@@ -26,11 +26,18 @@
|
||||
* The registry below is keyed by `TwoEndedModel`, so a name added to the plan's
|
||||
* two-ended list without a row seeded and counted here does not compile.
|
||||
*
|
||||
* What this still does not prove: that a restored row carries the right VALUES.
|
||||
* It asks whether the rows came back, not whether they came back intact —
|
||||
* `admin-backups-canonical-roundtrip.test.ts` is where field-level fidelity is
|
||||
* asserted. A restore that wrote one row per model with every column defaulted
|
||||
* would satisfy this file and fail that one.
|
||||
* What this still does not prove, with one exception: that a restored row
|
||||
* carries the right VALUES. It asks whether the rows came back, not whether
|
||||
* they came back intact — `admin-backups-canonical-roundtrip.test.ts` is where
|
||||
* field-level fidelity is asserted. A restore that wrote one row per model with
|
||||
* every column defaulted would satisfy this file and fail that one.
|
||||
*
|
||||
* The exception is the medication side effect. Its note lives in an encrypted
|
||||
* column beside a legacy plaintext one, so "the row came back" and "the note
|
||||
* came back" are genuinely different answers here: a restore that dropped the
|
||||
* ciphertext would still be counted as recovered by everything above. The
|
||||
* severity, the category and the entry are asserted alongside it, because a
|
||||
* side effect without them says something happened and not what.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -40,6 +47,7 @@ process.env.ENCRYPTION_KEY ??=
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { encrypt, encryptBytes } from "@/lib/crypto";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
@@ -74,6 +82,7 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
|
||||
const OWNER_ID = "round-trip-owner";
|
||||
const AT = (iso: string) => new Date(iso);
|
||||
const SIDE_EFFECT_NOTE = "nausea for two hours after the evening dose";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
@@ -94,6 +103,8 @@ const COUNT_BACK: Record<
|
||||
p.medicationSchedule.count({ where: { medication: { userId } } }),
|
||||
MedicationIntakeEvent: (p, userId) =>
|
||||
p.medicationIntakeEvent.count({ where: { userId } }),
|
||||
MedicationSideEffect: (p, userId) =>
|
||||
p.medicationSideEffect.count({ where: { userId } }),
|
||||
MoodEntry: (p, userId) => p.moodEntry.count({ where: { userId } }),
|
||||
MoodEntryTagLink: (p, userId) =>
|
||||
p.moodEntryTagLink.count({ where: { moodEntry: { userId } } }),
|
||||
@@ -183,6 +194,19 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
takenAt: AT("2026-07-01T08:04:00.000Z"),
|
||||
},
|
||||
});
|
||||
// The one row in this fixture that is also checked field by field after the
|
||||
// restore — see the assertion at the end of the test for why.
|
||||
await prisma.medicationSideEffect.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
medicationId: medication.id,
|
||||
occurredAt: AT("2026-07-01T21:00:00.000Z"),
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 3,
|
||||
notesEncrypted: encryptToBytes(SIDE_EFFECT_NOTE),
|
||||
},
|
||||
});
|
||||
|
||||
// A RATED tag the account defined itself — the export carries only rated
|
||||
// links, so a BINARY tag would leave `MoodEntryTagLink` empty.
|
||||
@@ -477,5 +501,34 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
"without them, which is a backup that reports success and hands back " +
|
||||
"less than it was given",
|
||||
).toEqual([]);
|
||||
|
||||
const sideEffect = await prisma.medicationSideEffect.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
include: { medication: { select: { name: true, userId: true } } },
|
||||
});
|
||||
expect({
|
||||
category: sideEffect.category,
|
||||
entry: sideEffect.entry,
|
||||
severity: sideEffect.severity,
|
||||
occurredAt: sideEffect.occurredAt.toISOString(),
|
||||
medication: sideEffect.medication.name,
|
||||
medicationOwner: sideEffect.medication.userId,
|
||||
note: readNote(sideEffect.notesEncrypted, sideEffect.notes),
|
||||
}).toEqual({
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 3,
|
||||
occurredAt: "2026-07-01T21:00:00.000Z",
|
||||
medication: "Round-trip tablet",
|
||||
medicationOwner: OWNER_ID,
|
||||
note: SIDE_EFFECT_NOTE,
|
||||
});
|
||||
// The note must be BACK IN THE COLUMN it came from, not carried as
|
||||
// plaintext: a restore that writes a portable export's decrypted note into
|
||||
// `notes` reads identically through `readNote` above and has quietly
|
||||
// un-encrypted the account's free text.
|
||||
expect(sideEffect.notes, "plaintext must not come back in the column").toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user