mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(backup): restore an erased account's copy into a chosen account
After an account is erased the operator still has its disaster-recovery file, and the upload refused it because its owner was gone. The refusal now carries backup.owner.missing, and the file can be sent again with a target in the query: an existing account that holds no records yet, confirmed by typing its username, or a new account created for the copy. The file's owner is rewritten to that account as it is read, so the stored copy, its preview, its download and its restore are that account's, and the restore that follows is the ordinary one. The new account has no password and no email; an admin sets its first password with the existing reset. A file whose own account is still here is refused with a target, and an account made for a refused file is removed again. The refusal of an unknown owner no longer writes the owner's id into the audit trail, since the erasure took every row that named it.
This commit is contained in:
1 parent
9baeb95bab
commit
8014fd4aae
5 files changed
+786
-25
No files matched your search
+65
-6
@@ -25090,10 +25090,52 @@ paths:
|
||||
same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the
|
||||
backup schema, was written by a newer schema version than this server reads, needs an encryption key this server
|
||||
does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account
|
||||
that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens
|
||||
cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
|
||||
/api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
|
||||
the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
|
||||
that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists
|
||||
here (erased, say) can be sent again with a target in the query: `into=existing&userId=<id>&confirm=<username>`
|
||||
for an account that holds no records yet, or `into=new&username=<name>` for a new account created for the copy,
|
||||
with no password and no email (an admin sets its first password with the existing password reset). The file's
|
||||
owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like
|
||||
any other; the erased account's id is not stored. A target is refused before the file is read when the account
|
||||
does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does
|
||||
not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three
|
||||
uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A
|
||||
browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last
|
||||
five minutes (with a second factor, on an account that has one). Without it the answer is 401
|
||||
`auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
|
||||
parameters:
|
||||
- in: query
|
||||
name: into
|
||||
schema:
|
||||
description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account,
|
||||
which has to exist."
|
||||
type: string
|
||||
enum:
|
||||
- existing
|
||||
- new
|
||||
description: "Where the copy goes when the account the file names does not exist here. Absent: the file's own account,
|
||||
which has to exist."
|
||||
- in: query
|
||||
name: userId
|
||||
schema:
|
||||
description: "With `into=existing`: the account's id."
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: "With `into=existing`: the account's id."
|
||||
- in: query
|
||||
name: confirm
|
||||
schema:
|
||||
description: "With `into=existing`: the account's username, typed by the operator."
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: "With `into=existing`: the account's username, typed by the operator."
|
||||
- in: query
|
||||
name: username
|
||||
schema:
|
||||
description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)."
|
||||
type: string
|
||||
minLength: 3
|
||||
maxLength: 30
|
||||
description: "With `into=new`: the new account's username (letters, digits, `_` and `-`)."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -25139,6 +25181,22 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"403": *a25
|
||||
"404":
|
||||
description: The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: "The chosen target cannot take the copy, and nothing was stored: a managed profile
|
||||
(`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an
|
||||
account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username
|
||||
already taken (`backup.target.username_taken`), or a file whose own account is still here
|
||||
(`backup.owner.exists`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"413":
|
||||
description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this
|
||||
server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` =
|
||||
@@ -25150,8 +25208,9 @@ paths:
|
||||
"422":
|
||||
description: "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as
|
||||
gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not
|
||||
hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist
|
||||
here."
|
||||
hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here
|
||||
(`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose
|
||||
`confirm` is not the account's username (`backup.target.unconfirmed`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@@ -20,6 +20,17 @@
|
||||
* checked as it goes and stored in the same pass; a file that fails any check
|
||||
* rolls the store back, so nothing of it remains.
|
||||
*
|
||||
* The file names the account it belongs to, and that account has to exist
|
||||
* here. When it does not (the account was erased, and the operator still has
|
||||
* its disaster-recovery file), the upload answers 422 with
|
||||
* `backup.owner.missing`, and the operator can send the file again with a
|
||||
* target in the query (`backup-upload-target.ts`): an existing account that
|
||||
* holds no records yet, confirmed by typing its username, or a new account
|
||||
* created for the copy. The file's owner is then rewritten to that account as
|
||||
* the bytes come in (`backup-owner-rewrite.ts`), so the copy stored is that
|
||||
* account's copy, and the restore that follows is the ordinary one. The new
|
||||
* account has no password; an admin sets its first one in Admin, Users.
|
||||
*
|
||||
* Crucially, this route does NOT execute a restore. It only stores the
|
||||
* file. The separate `POST /api/admin/backups/[id]/restore` endpoint
|
||||
* (criterion 3) actually replaces user data — keeping the two phases
|
||||
@@ -32,6 +43,7 @@ import { Readable } from "node:stream";
|
||||
|
||||
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -40,9 +52,19 @@ import {
|
||||
requireAdmin,
|
||||
assertRecentCookieProof,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { BackupJsonError } from "@/lib/export/backup-json-scan";
|
||||
import {
|
||||
BackupOwnerRewriteError,
|
||||
rewriteBackupOwner,
|
||||
type BackupOwnerRewrite,
|
||||
} from "@/lib/export/backup-owner-rewrite";
|
||||
import {
|
||||
BackupUploadDecodeError,
|
||||
decodeBackupUpload,
|
||||
@@ -57,6 +79,11 @@ import {
|
||||
BACKUP_UPLOAD_TOO_LARGE_CODE,
|
||||
BackupBlobTooLargeError,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import {
|
||||
checkRestoreTarget,
|
||||
RESTORE_TARGET_REFUSALS,
|
||||
type RestoreTargetVerdict,
|
||||
} from "@/lib/export/restore-target";
|
||||
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
|
||||
import {
|
||||
readStreamedBackup,
|
||||
@@ -66,6 +93,11 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import type { BackupPayload } from "@/lib/validations/backup";
|
||||
import {
|
||||
BACKUP_OWNER_EXISTS_CODE,
|
||||
BACKUP_OWNER_MISSING_CODE,
|
||||
parseBackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
summarizeBackup,
|
||||
@@ -111,6 +143,21 @@ class UploadRefused extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The answer and audit details for a target the copy may not go into. */
|
||||
function targetRefused(
|
||||
verdict: Extract<RestoreTargetVerdict, { ok: false }>,
|
||||
userId: string,
|
||||
): UploadRefused {
|
||||
const refusal = RESTORE_TARGET_REFUSALS[verdict.reason];
|
||||
const models = verdict.models.length > 0 ? { models: verdict.models } : {};
|
||||
return new UploadRefused(
|
||||
refusal.status,
|
||||
refusal.message,
|
||||
{ reason: `target_${verdict.reason}`, targetUserId: userId, ...models },
|
||||
{ errorCode: refusal.code, ...models },
|
||||
);
|
||||
}
|
||||
|
||||
/** The file's bytes, gunzipped and counted on both ends. */
|
||||
async function* decodedBytes(
|
||||
source: AsyncIterable<Uint8Array>,
|
||||
@@ -129,6 +176,26 @@ async function* decodedBytes(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the account an upload created for its copy, when nothing was stored.
|
||||
* It holds nothing and nobody can have signed into it (no password, no email),
|
||||
* so the row is all there is. A failure is noted, never thrown over the
|
||||
* refusal the operator needs to see.
|
||||
*/
|
||||
async function discardCreatedAccount(userId: string): Promise<void> {
|
||||
try {
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
} catch (err) {
|
||||
annotate({
|
||||
meta: {
|
||||
backup_upload_account_discard_failed: true,
|
||||
backup_upload_account_discard_error:
|
||||
err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
// A fresh proof on top of the admin session: this reaches every account's
|
||||
@@ -155,6 +222,49 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
details: { reason, ...extra },
|
||||
});
|
||||
|
||||
// The account the copy goes into when the file's own account is gone.
|
||||
// Decided before a byte of the file is read, so a target that will be
|
||||
// refused costs no upload; checked again once the file has been read.
|
||||
const parsedTarget = parseBackupUploadTarget(
|
||||
new URL(request.url).searchParams,
|
||||
);
|
||||
if (!parsedTarget.success) {
|
||||
await denied("target_invalid");
|
||||
return returnAllZodIssues(parsedTarget.error);
|
||||
}
|
||||
const choice = parsedTarget.data;
|
||||
let target: { id: string; username: string; created: boolean } | null = null;
|
||||
if (choice?.into === "existing") {
|
||||
const verdict = await checkRestoreTarget(prisma, choice.userId);
|
||||
if (!verdict.ok) {
|
||||
const refusal = targetRefused(verdict, choice.userId);
|
||||
await denied(refusal.details.reason as string, refusal.details);
|
||||
return apiError(refusal.answer, refusal.status, refusal.meta);
|
||||
}
|
||||
// The restore writes into this account, so the operator names it.
|
||||
if (choice.confirm.trim() !== verdict.target.username) {
|
||||
await denied("target_unconfirmed", { targetUserId: choice.userId });
|
||||
return apiError(
|
||||
"Type the chosen account's username to confirm. Nothing was stored.",
|
||||
422,
|
||||
{ errorCode: "backup.target.unconfirmed" },
|
||||
);
|
||||
}
|
||||
target = { ...verdict.target, created: false };
|
||||
} else if (choice?.into === "new") {
|
||||
const taken = await prisma.user.findUnique({
|
||||
where: { username: choice.username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (taken) {
|
||||
await denied("target_username_taken");
|
||||
return apiError("That username is already taken.", 409, {
|
||||
errorCode: "backup.target.username_taken",
|
||||
});
|
||||
}
|
||||
}
|
||||
annotate({ meta: { backup_upload_into: choice?.into ?? "owner" } });
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
const multipart = contentType.startsWith("multipart/form-data");
|
||||
const limit = multipart ? MAX_UPLOAD_BYTES : MAX_RAW_UPLOAD_BYTES;
|
||||
@@ -226,6 +336,32 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// answers with and the key uses it already holds (`backup-preview.ts`).
|
||||
let keys: BackupKeyIdCollector | undefined;
|
||||
let created: { id: string };
|
||||
let rewrite: BackupOwnerRewrite | null = null;
|
||||
if (choice?.into === "new") {
|
||||
// Created before the file is read, because the file is rewritten to the
|
||||
// account's id on its way in; deleted again below when nothing is
|
||||
// stored. No password, no email: the account cannot be signed into
|
||||
// until an admin sets its first password (Admin, Users).
|
||||
try {
|
||||
const account = await prisma.user.create({
|
||||
data: { username: choice.username, role: "USER" },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
target = { ...account, created: true };
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === "P2002"
|
||||
) {
|
||||
await denied("target_username_taken");
|
||||
return apiError("That username is already taken.", 409, {
|
||||
errorCode: "backup.target.username_taken",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const chosen = target;
|
||||
try {
|
||||
created = await storeBackupBlob(
|
||||
prisma,
|
||||
@@ -247,8 +383,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
async (write) => {
|
||||
// One pass: every chunk is stored as it is checked, and a check that
|
||||
// fails throws, which rolls the store back.
|
||||
const decoded = decodedBytes(source, limit);
|
||||
if (chosen) rewrite = rewriteBackupOwner(decoded, chosen.id);
|
||||
async function* storedAsRead() {
|
||||
for await (const chunk of decodedBytes(source, limit)) {
|
||||
for await (const chunk of rewrite ? rewrite.bytes : decoded) {
|
||||
await write(Buffer.from(chunk));
|
||||
yield chunk;
|
||||
}
|
||||
@@ -269,6 +407,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
if (err instanceof BackupOwnerRewriteError) {
|
||||
const issues = [{ path: "userId", message: err.message }];
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
"Backup payload failed schema validation",
|
||||
{ reason: "schema_invalid", issues },
|
||||
{ issues },
|
||||
);
|
||||
}
|
||||
if (err instanceof StreamedBackupInvalidError) {
|
||||
const issues = [{ path: err.path, message: err.message }];
|
||||
throw new UploadRefused(
|
||||
@@ -335,6 +482,34 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (chosen) {
|
||||
// The file now names the chosen account. The account it named
|
||||
// before has to be gone: a copy of an account that is still here
|
||||
// restores into that account, never into another one.
|
||||
const declared = rewrite?.declaredOwner() ?? null;
|
||||
const declaredOwner = declared
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: declared },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (!declared || declaredOwner) {
|
||||
throw new UploadRefused(
|
||||
409,
|
||||
"This file belongs to an account on this server and restores into that account. Upload it without choosing an account.",
|
||||
{ reason: "owner_exists", ownerId: declared },
|
||||
{ errorCode: BACKUP_OWNER_EXISTS_CODE },
|
||||
);
|
||||
}
|
||||
// The file took a while to read; the account may have changed.
|
||||
if (!chosen.created) {
|
||||
const again = await checkRestoreTarget(prisma, chosen.id);
|
||||
if (!again.ok) throw targetRefused(again, chosen.id);
|
||||
}
|
||||
owner = { id: chosen.id, username: chosen.username };
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the userId on the file points at a user that exists in
|
||||
// this DB. Restore would fail later anyway, but failing here gives
|
||||
// the admin a precise error before any side-effect.
|
||||
@@ -343,15 +518,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
if (!owner) {
|
||||
// Most often an account that was erased. Its id is not written
|
||||
// into the trail: the erasure took every row that named it, and a
|
||||
// refused upload is not a reason to bring it back. The answer says
|
||||
// what the operator can do instead.
|
||||
throw new UploadRefused(
|
||||
422,
|
||||
`Backup is for user '${payload.userId}' which does not exist in this DB`,
|
||||
{ reason: "owner_not_found", ownerId: payload.userId },
|
||||
"This file belongs to an account that does not exist on this server. Choose an account to restore it into.",
|
||||
{ reason: "owner_not_found" },
|
||||
{ errorCode: BACKUP_OWNER_MISSING_CODE },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// Nothing was stored, so the account made for the copy goes again.
|
||||
if (chosen?.created) await discardCreatedAccount(chosen.id);
|
||||
if (err instanceof UploadRefused) {
|
||||
await denied(err.details.reason as string, err.details);
|
||||
return apiError(err.answer, err.status, err.meta);
|
||||
@@ -380,6 +562,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
};
|
||||
const storedOwner = owner as { id: string; username: string } | null;
|
||||
|
||||
if (chosen?.created) {
|
||||
await auditLog("admin.user.create", {
|
||||
userId: admin.id,
|
||||
ipAddress,
|
||||
details: {
|
||||
targetUserId: chosen.id,
|
||||
username: chosen.username,
|
||||
reason: "backup_restore",
|
||||
backupId: created.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
await auditLog("admin.backups.upload", {
|
||||
userId: admin.id,
|
||||
ipAddress,
|
||||
@@ -387,6 +581,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
backupId: created.id,
|
||||
ownerId: storedOwner?.id,
|
||||
ownerUsername: storedOwner?.username,
|
||||
// Set when the file's own account was gone and the copy was pointed at
|
||||
// this one. The erased account's id is not recorded.
|
||||
...(chosen ? { into: chosen.created ? "new" : "existing" } : {}),
|
||||
type: uploadType,
|
||||
schemaVersion: summary.schemaVersion,
|
||||
counts: {
|
||||
|
||||
@@ -218,7 +218,26 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Admin"],
|
||||
summary: "Store an uploaded backup file as a new copy",
|
||||
description:
|
||||
"Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
|
||||
"Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here (`meta.errorCode` = `backup.owner.missing`). A file of an account that no longer exists here (erased, say) can be sent again with a target in the query: `into=existing&userId=<id>&confirm=<username>` for an account that holds no records yet, or `into=new&username=<name>` for a new account created for the copy, with no password and no email (an admin sets its first password with the existing password reset). The file's owner is rewritten to that account as it is read, so the stored copy is that account's copy and restores like any other; the erased account's id is not stored. A target is refused before the file is read when the account does not exist, is a managed profile, is pending deletion, or holds records (a restore replaces records, it does not merge them); a file whose own account is still here is refused with a target (`backup.owner.exists`). Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
into: z.enum(["existing", "new"]).optional().meta({
|
||||
description:
|
||||
"Where the copy goes when the account the file names does not exist here. Absent: the file's own account, which has to exist.",
|
||||
}),
|
||||
userId: z.string().max(64).optional().meta({
|
||||
description: "With `into=existing`: the account's id.",
|
||||
}),
|
||||
confirm: z.string().max(64).optional().meta({
|
||||
description:
|
||||
"With `into=existing`: the account's username, typed by the operator.",
|
||||
}),
|
||||
username: z.string().min(3).max(30).optional().meta({
|
||||
description:
|
||||
"With `into=new`: the new account's username (letters, digits, `_` and `-`).",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -261,6 +280,16 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with `meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The chosen target cannot take the copy, and nothing was stored: a managed profile (`backup.target.managed_profile`), an account pending deletion (`backup.target.pending_deletion`), an account that already holds records (`backup.target.holds_records`, `meta.models` naming them), a username already taken (`backup.target.username_taken`), or a file whose own account is still here (`backup.owner.exists`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description:
|
||||
"Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = `backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored.",
|
||||
@@ -268,7 +297,7 @@ export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist here.",
|
||||
"The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), an owner account that does not exist here (`backup.owner.missing`), a target query that does not parse (`details.issues`), or an `into=existing` whose `confirm` is not the account's username (`backup.target.unconfirmed`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Where an uploaded backup goes when the account it names no longer exists.
|
||||
*
|
||||
* The upload's body is the file itself, so the choice rides in the query:
|
||||
*
|
||||
* - `into=existing&userId=<id>&confirm=<username>`: an existing account,
|
||||
* confirmed by typing its username, since the restore writes into it;
|
||||
* - `into=new&username=<name>`: a new account created for the copy.
|
||||
*
|
||||
* No query at all is the upload as it always was: the copy goes to the
|
||||
* account the file names, which has to exist.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { accountUsernameSchema } from "@/lib/validations/auth";
|
||||
|
||||
/** The upload's refusal when the file's own account is not on this server. */
|
||||
export const BACKUP_OWNER_MISSING_CODE = "backup.owner.missing";
|
||||
/** The upload's refusal when a target was chosen for a file whose account exists. */
|
||||
export const BACKUP_OWNER_EXISTS_CODE = "backup.owner.exists";
|
||||
|
||||
export const backupUploadTargetSchema = z.discriminatedUnion("into", [
|
||||
z
|
||||
.object({
|
||||
into: z.literal("existing"),
|
||||
userId: z.string().min(1).max(64),
|
||||
confirm: z.string().max(64),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
into: z.literal("new"),
|
||||
username: accountUsernameSchema,
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export type BackupUploadTarget = z.infer<typeof backupUploadTargetSchema>;
|
||||
|
||||
const TARGET_PARAMS = ["into", "userId", "confirm", "username"] as const;
|
||||
|
||||
/**
|
||||
* Read the target from the upload's query. Null when the query names none of
|
||||
* its parameters; a query that names any of them is held to the schema whole.
|
||||
*/
|
||||
export function parseBackupUploadTarget(
|
||||
params: URLSearchParams,
|
||||
):
|
||||
| { success: true; data: BackupUploadTarget | null }
|
||||
| { success: false; error: z.ZodError } {
|
||||
if (!TARGET_PARAMS.some((name) => params.has(name))) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
const parsed = backupUploadTargetSchema.safeParse(
|
||||
Object.fromEntries(params.entries()),
|
||||
);
|
||||
return parsed.success
|
||||
? { success: true, data: parsed.data }
|
||||
: { success: false, error: parsed.error };
|
||||
}
|
||||
|
||||
/** The query string for a target, as the admin console sends it. */
|
||||
export function backupUploadTargetQuery(target: BackupUploadTarget): string {
|
||||
const params = new URLSearchParams(
|
||||
target.into === "existing"
|
||||
? { into: "existing", userId: target.userId, confirm: target.confirm }
|
||||
: { into: "new", username: target.username },
|
||||
);
|
||||
return params.toString();
|
||||
}
|
||||
@@ -9,24 +9,43 @@
|
||||
* session, and a server that no longer knows the account.
|
||||
*
|
||||
* 1. The purge leaves nothing in the database that names the account.
|
||||
* 2. The admin upload refuses the file, because its owner is gone, and
|
||||
* stores nothing (the documented contract in `docs/ops/backup-restore.md`:
|
||||
* a restore writes into an account that exists under the same id).
|
||||
* 3. With the account row put back under its original id, the one way that
|
||||
* contract leaves, the upload and the restore bring every section back.
|
||||
* 2. The admin upload without a target refuses the file, because its owner
|
||||
* is gone, says so with `backup.owner.missing`, stores nothing, and does
|
||||
* not write the erased id into the trail either.
|
||||
* 3. With the account row put back under its original id, the upload and
|
||||
* the restore bring every section back: the same-id path, unchanged.
|
||||
* 4. Into a freshly created account, with the file's owner set to that
|
||||
* account, the restore brings every section back and nothing in the
|
||||
* database names the erased account afterwards.
|
||||
* account by hand, the restore brings every section back and nothing in
|
||||
* the database names the erased account afterwards. This is the
|
||||
* mechanism the upload's target generalises.
|
||||
* 5. The upload with a target does it for the operator: into an existing
|
||||
* empty account (typed confirmation) and into a new account it creates,
|
||||
* every section comes back, the audit trail says what happened, and
|
||||
* nothing names the erased account, the refused first upload included.
|
||||
* 6. A target that holds records, is a managed profile, is pending deletion
|
||||
* or is not confirmed is refused before the file is read; so is a target
|
||||
* for a file whose own account is still here, and a new account made for
|
||||
* a refused file is removed again. A non-admin session and a Bearer
|
||||
* token are refused outright.
|
||||
*
|
||||
* Everything runs through production code: the deletion route, the purge job,
|
||||
* the admin upload and restore routes and the restore job
|
||||
* the admin upload, target-check and restore routes and the restore job
|
||||
* (`restore-job-driver.ts` runs the queued job in-process).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import {
|
||||
openStoredBackup,
|
||||
STORED_BACKUP_SELECT,
|
||||
} from "@/lib/export/stored-backup";
|
||||
import {
|
||||
backupUploadTargetQuery,
|
||||
type BackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
|
||||
import {
|
||||
OWNER_ID,
|
||||
@@ -207,10 +226,10 @@ async function eraseAfterExport(prisma: PrismaClient) {
|
||||
return { admin, json, counts };
|
||||
}
|
||||
|
||||
async function upload(body: string) {
|
||||
async function upload(body: string, query = "") {
|
||||
const { POST } = await import("@/app/api/admin/backups/upload/route");
|
||||
const res = await POST(
|
||||
new Request("http://localhost/api/admin/backups/upload", {
|
||||
new Request(`http://localhost/api/admin/backups/upload${query}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body,
|
||||
@@ -221,7 +240,7 @@ async function upload(body: string) {
|
||||
body: (await res.json()) as {
|
||||
data: { id: string } | null;
|
||||
error: string | null;
|
||||
meta?: { reason?: string; errorCode?: string };
|
||||
meta?: { reason?: string; errorCode?: string; models?: string[] };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -245,6 +264,52 @@ async function restore(backupId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The upload, with a target in the query as the backups console sends it. */
|
||||
function uploadInto(body: string, target: BackupUploadTarget) {
|
||||
return upload(body, `?${backupUploadTargetQuery(target)}`);
|
||||
}
|
||||
|
||||
async function checkTarget(userId: string) {
|
||||
const { GET } =
|
||||
await import("@/app/api/admin/backups/targets/[userId]/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const res = await GET(
|
||||
new NextRequest(`http://localhost/api/admin/backups/targets/${userId}`),
|
||||
{ params: Promise.resolve({ userId }) },
|
||||
);
|
||||
return {
|
||||
status: res.status,
|
||||
body: (await res.json()) as {
|
||||
data: {
|
||||
id: string;
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
models: string[];
|
||||
} | null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The stored copy as the restore will read it, as text. */
|
||||
async function storedCopyText(prisma: PrismaClient, backupId: string) {
|
||||
const row = await prisma.dataBackup.findUniqueOrThrow({
|
||||
where: { id: backupId },
|
||||
select: STORED_BACKUP_SELECT,
|
||||
});
|
||||
const source = await openStoredBackup(prisma, row);
|
||||
const parts: Buffer[] = [];
|
||||
for await (const part of source()) parts.push(Buffer.from(part));
|
||||
return { row, text: Buffer.concat(parts).toString("utf8") };
|
||||
}
|
||||
|
||||
async function auditDetails(prisma: PrismaClient, action: string) {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: { action },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return rows.map((row) => JSON.parse(row.details ?? "{}") as object);
|
||||
}
|
||||
|
||||
async function countsOf(prisma: PrismaClient, userId: string) {
|
||||
const { counts } = await buildFullBackupPayload(prisma, userId, {
|
||||
purpose: "disaster-recovery",
|
||||
@@ -266,14 +331,16 @@ describe("restoring the backup of an erased account", () => {
|
||||
|
||||
const refused = await upload(json);
|
||||
expect(refused.status, JSON.stringify(refused.body)).toBe(422);
|
||||
expect(refused.body.meta?.errorCode).toBe("backup.owner.missing");
|
||||
const denial = await prisma.auditLog.findFirstOrThrow({
|
||||
where: { action: "admin.backups.upload.denied" },
|
||||
});
|
||||
expect(JSON.parse(denial.details ?? "{}")).toMatchObject({
|
||||
expect(JSON.parse(denial.details ?? "{}")).toEqual({
|
||||
reason: "owner_not_found",
|
||||
ownerId: OWNER_ID,
|
||||
});
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
// The refusal wrote a row, and that row does not bring the id back.
|
||||
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
@@ -324,3 +391,342 @@ describe("restoring the backup of an erased account", () => {
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
|
||||
describe("restoring the backup of an erased account into a chosen account", () => {
|
||||
it(
|
||||
"into an existing empty account: every section, the trail, and nothing naming the erased one",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { json, counts } = await eraseAfterExport(prisma);
|
||||
const fresh = await prisma.user.create({
|
||||
data: {
|
||||
username: "returning-person",
|
||||
email: "returning-person@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
// The operator's first try, which the console turns into the choice.
|
||||
const first = await upload(json);
|
||||
expect(first.body.meta?.errorCode).toBe("backup.owner.missing");
|
||||
|
||||
const check = await checkTarget(fresh.id);
|
||||
expect(check.status).toBe(200);
|
||||
expect(check.body.data).toMatchObject({
|
||||
id: fresh.id,
|
||||
eligible: true,
|
||||
reason: null,
|
||||
models: [],
|
||||
});
|
||||
|
||||
const stored = await uploadInto(json, {
|
||||
into: "existing",
|
||||
userId: fresh.id,
|
||||
confirm: "returning-person",
|
||||
});
|
||||
expect(stored.status, JSON.stringify(stored.body)).toBe(201);
|
||||
const backupId = stored.body.data!.id;
|
||||
|
||||
// The stored copy is the chosen account's copy, inside as well.
|
||||
const copy = await storedCopyText(prisma, backupId);
|
||||
expect(copy.row.userId).toBe(fresh.id);
|
||||
expect(JSON.parse(copy.text).userId).toBe(fresh.id);
|
||||
expect(copy.text.includes(OWNER_ID)).toBe(false);
|
||||
|
||||
const restored = await restore(backupId);
|
||||
expect(restored.status, JSON.stringify(restored.body)).toBe(200);
|
||||
expect(restored.body.data!.skipped.links).toBe(0);
|
||||
expect(await countsOf(prisma, fresh.id)).toEqual(counts);
|
||||
|
||||
expect(await auditDetails(prisma, "admin.backups.upload")).toEqual([
|
||||
expect.objectContaining({
|
||||
backupId,
|
||||
ownerId: fresh.id,
|
||||
ownerUsername: "returning-person",
|
||||
into: "existing",
|
||||
}),
|
||||
]);
|
||||
expect(await auditDetails(prisma, "admin.backups.restore")).toEqual([
|
||||
expect.objectContaining({ backupId, ownerId: fresh.id }),
|
||||
]);
|
||||
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"into a new account: created without a password, filled, and signed into through the admin reset",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
vi.stubEnv("PASSWORD_BREACH_CHECK_DISABLED", "1");
|
||||
const { admin, json, counts } = await eraseAfterExport(prisma);
|
||||
const usersBefore = await prisma.user.count();
|
||||
|
||||
const stored = await uploadInto(json, {
|
||||
into: "new",
|
||||
username: "returning-person",
|
||||
});
|
||||
expect(stored.status, JSON.stringify(stored.body)).toBe(201);
|
||||
const backupId = stored.body.data!.id;
|
||||
expect(await prisma.user.count()).toBe(usersBefore + 1);
|
||||
const account = await prisma.user.findUniqueOrThrow({
|
||||
where: { username: "returning-person" },
|
||||
});
|
||||
expect(account).toMatchObject({
|
||||
role: "USER",
|
||||
email: null,
|
||||
passwordHash: null,
|
||||
managedProfileAt: null,
|
||||
deletionRequestedAt: null,
|
||||
});
|
||||
expect(
|
||||
(await prisma.dataBackup.findUniqueOrThrow({ where: { id: backupId } }))
|
||||
.userId,
|
||||
).toBe(account.id);
|
||||
|
||||
const restored = await restore(backupId);
|
||||
expect(restored.status, JSON.stringify(restored.body)).toBe(200);
|
||||
expect(restored.body.data!.skipped.links).toBe(0);
|
||||
expect(await countsOf(prisma, account.id)).toEqual(counts);
|
||||
// Identity is never in a restore: still the account the upload made.
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({
|
||||
where: { id: account.id },
|
||||
select: { username: true, role: true, passwordHash: true },
|
||||
}),
|
||||
).toEqual({
|
||||
username: "returning-person",
|
||||
role: "USER",
|
||||
passwordHash: null,
|
||||
});
|
||||
|
||||
expect(await auditDetails(prisma, "admin.user.create")).toEqual([
|
||||
{
|
||||
targetUserId: account.id,
|
||||
username: "returning-person",
|
||||
reason: "backup_restore",
|
||||
backupId,
|
||||
},
|
||||
]);
|
||||
expect(await auditDetails(prisma, "admin.backups.upload")).toEqual([
|
||||
expect.objectContaining({ ownerId: account.id, into: "new" }),
|
||||
]);
|
||||
expect(await cellsNaming(prisma, OWNER_ID)).toEqual([]);
|
||||
|
||||
// How the person gets in: the admin sets a first password with the
|
||||
// existing reset, which is all the new account needs.
|
||||
const { POST: resetPassword } =
|
||||
await import("@/app/api/admin/users/[id]/reset-password/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const reset = await resetPassword(
|
||||
new NextRequest(
|
||||
`http://localhost/api/admin/users/${account.id}/reset-password`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ password: "Harbour-lantern-47-quietly" }),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ id: account.id }) },
|
||||
);
|
||||
expect(reset.status, await reset.clone().text()).toBe(200);
|
||||
expect(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: account.id } }))
|
||||
.passwordHash,
|
||||
).not.toBeNull();
|
||||
expect(admin.role).toBe("ADMIN");
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"refuses an account that holds records, a managed profile, an account pending deletion and a wrong confirmation, before reading the file",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { json } = await eraseAfterExport(prisma);
|
||||
const used = await prisma.user.create({
|
||||
data: { username: "in-use", email: "in-use@example.test" },
|
||||
});
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: used.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-10-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const managed = await prisma.user.create({
|
||||
data: { username: "managed-cafe01", managedProfileAt: new Date() },
|
||||
});
|
||||
const leaving = await prisma.user.create({
|
||||
data: {
|
||||
username: "leaving",
|
||||
deletionRequestedAt: new Date(),
|
||||
deletionPurgeAt: new Date(Date.now() + 14 * DAY_MS),
|
||||
},
|
||||
});
|
||||
const empty = await prisma.user.create({ data: { username: "empty" } });
|
||||
|
||||
expect((await checkTarget(used.id)).body.data).toMatchObject({
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
});
|
||||
expect((await checkTarget("no-such-account")).status).toBe(404);
|
||||
|
||||
const cases: Array<[BackupUploadTarget, number, string]> = [
|
||||
[
|
||||
{ into: "existing", userId: used.id, confirm: "in-use" },
|
||||
409,
|
||||
"backup.target.holds_records",
|
||||
],
|
||||
[
|
||||
{ into: "existing", userId: managed.id, confirm: "managed-cafe01" },
|
||||
409,
|
||||
"backup.target.managed_profile",
|
||||
],
|
||||
[
|
||||
{ into: "existing", userId: leaving.id, confirm: "leaving" },
|
||||
409,
|
||||
"backup.target.pending_deletion",
|
||||
],
|
||||
[
|
||||
{ into: "existing", userId: "no-such-account", confirm: "x" },
|
||||
404,
|
||||
"backup.target.not_found",
|
||||
],
|
||||
[
|
||||
{ into: "existing", userId: empty.id, confirm: "Empty" },
|
||||
422,
|
||||
"backup.target.unconfirmed",
|
||||
],
|
||||
[
|
||||
{ into: "new", username: "in-use" },
|
||||
409,
|
||||
"backup.target.username_taken",
|
||||
],
|
||||
];
|
||||
for (const [target, status, code] of cases) {
|
||||
await prisma.rateLimit.deleteMany();
|
||||
const refused = await uploadInto(json, target);
|
||||
expect(refused.status, JSON.stringify(refused.body)).toBe(status);
|
||||
expect(refused.body.meta?.errorCode).toBe(code);
|
||||
}
|
||||
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
expect(
|
||||
await prisma.measurement.count({ where: { userId: used.id } }),
|
||||
).toBe(1);
|
||||
expect(
|
||||
(await auditDetails(prisma, "admin.backups.upload.denied")).map(
|
||||
(details) => (details as { reason: string }).reason,
|
||||
),
|
||||
).toEqual([
|
||||
"target_holds_records",
|
||||
"target_managed_profile",
|
||||
"target_pending_deletion",
|
||||
"target_not_found",
|
||||
"target_unconfirmed",
|
||||
"target_username_taken",
|
||||
]);
|
||||
|
||||
// A query that does not parse is refused with every issue.
|
||||
await prisma.rateLimit.deleteMany();
|
||||
const malformed = await upload(json, "?into=new&username=no%20spaces");
|
||||
expect(malformed.status).toBe(422);
|
||||
expect(
|
||||
await prisma.user.count({ where: { username: "no spaces" } }),
|
||||
).toBe(0);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"refuses a target for a file whose own account is still here, and removes an account made for it",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { json } = await eraseAfterExport(prisma);
|
||||
await createOwner(prisma);
|
||||
const empty = await prisma.user.create({ data: { username: "empty" } });
|
||||
const usersBefore = await prisma.user.count();
|
||||
|
||||
const existing = await uploadInto(json, {
|
||||
into: "existing",
|
||||
userId: empty.id,
|
||||
confirm: "empty",
|
||||
});
|
||||
expect(existing.status, JSON.stringify(existing.body)).toBe(409);
|
||||
expect(existing.body.meta?.errorCode).toBe("backup.owner.exists");
|
||||
|
||||
await prisma.rateLimit.deleteMany();
|
||||
const fresh = await uploadInto(json, {
|
||||
into: "new",
|
||||
username: "would-be-copy",
|
||||
});
|
||||
expect(fresh.status, JSON.stringify(fresh.body)).toBe(409);
|
||||
expect(fresh.body.meta?.errorCode).toBe("backup.owner.exists");
|
||||
expect(await prisma.user.count()).toBe(usersBefore);
|
||||
expect(
|
||||
await prisma.user.count({ where: { username: "would-be-copy" } }),
|
||||
).toBe(0);
|
||||
expect(await auditDetails(prisma, "admin.user.create")).toEqual([]);
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
|
||||
// Without a target, the same file goes to its own account as before.
|
||||
await prisma.rateLimit.deleteMany();
|
||||
const own = await upload(json);
|
||||
expect(own.status, JSON.stringify(own.body)).toBe(201);
|
||||
expect(
|
||||
(
|
||||
await prisma.dataBackup.findUniqueOrThrow({
|
||||
where: { id: own.body.data!.id },
|
||||
})
|
||||
).userId,
|
||||
).toBe(OWNER_ID);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
|
||||
it(
|
||||
"refuses a non-admin session and an admin's Bearer token, and creates nothing",
|
||||
async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { admin, json } = await eraseAfterExport(prisma);
|
||||
const member = await prisma.user.create({ data: { username: "member" } });
|
||||
const empty = await prisma.user.create({ data: { username: "empty" } });
|
||||
const usersBefore = await prisma.user.count();
|
||||
|
||||
cookieJar.clear();
|
||||
await signIn(prisma, member.id);
|
||||
const asMember = await uploadInto(json, {
|
||||
into: "new",
|
||||
username: "member-made",
|
||||
});
|
||||
expect(asMember.status).toBe(403);
|
||||
expect((await checkTarget(empty.id)).status).toBe(403);
|
||||
|
||||
cookieJar.clear();
|
||||
const raw = `hlk_erasedrestore_${"0".repeat(48)}`;
|
||||
await prisma.apiToken.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
name: "wildcard",
|
||||
tokenHash: hashToken(raw),
|
||||
permissions: ["*"],
|
||||
},
|
||||
});
|
||||
headerJar.set("authorization", `Bearer ${raw}`);
|
||||
const asBearer = await uploadInto(json, {
|
||||
into: "existing",
|
||||
userId: empty.id,
|
||||
confirm: "empty",
|
||||
});
|
||||
expect(asBearer.status).toBe(401);
|
||||
expect((await checkTarget(empty.id)).status).toBe(401);
|
||||
|
||||
expect(await prisma.user.count()).toBe(usersBefore);
|
||||
expect(await prisma.dataBackup.count()).toBe(0);
|
||||
},
|
||||
SLOW,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user