Skip to content
Draft
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
23 changes: 13 additions & 10 deletions .sdkharness/tests/conformance/lock.legal_hold.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const REASONS = new Set([
// keeps it -- and anything else registered below -- out of
// every detail string all the same.
const guard = require('../lib/guard.cjs');
const { isLegalHoldRefusal } = require('../lib/legal-hold-refusal.cjs');

// Proxy variables must not reroute a loopback request.
guard.scrubProxyEnv();
Expand Down Expand Up @@ -273,21 +274,23 @@ async function run() {
'metadata reports legalHold ' + JSON.stringify(held.legalHold) + ', expected on');
}

// The refusal must name the legal hold (HTTP 400/403 from the lock layer),
// not any thrown error.
// The refusal is either real B2's 401 access_denied or the TypeScript SDK
// simulator profile's 400/403 file_lock_legal_hold_protected
// (lib/legal-hold-refusal.cjs). Anything else -- a bad token, a 5xx, a
// network error -- is the wrong reason. 401 access_denied is what real B2
// answers (measured 2026-11-10, b2-simulator ledger F-139); the other
// values are that profile's own codes.
const refusal = await guard.capture(() =>
bucket.deleteFileVersion(OBJECT_NAME, uploaded.fileId));
if (!refusal) {
throw new Failure('delete under hold', 'a version on legal hold was deleted');
}
// NOTE: the error type/status/code values below are derived from the simulator. Re-verify them against real
// B2 before using this assertion outside the simulator.
const wrong = guard.explainMismatch(refusal, {
statuses: [400, 403],
codes: ['file_lock_legal_hold_protected'],
message: /legal.?hold/i,
});
if (wrong) {
if (!isLegalHoldRefusal(refusal)) {
const wrong = guard.explainMismatch(refusal, {
statuses: [400, 403],
codes: ['file_lock_legal_hold_protected'],
message: /legal.?hold/i,
});
throw new Failure('delete under hold', 'refused for the wrong reason: ' + wrong);
}

Expand Down
31 changes: 31 additions & 0 deletions .sdkharness/tests/lib/legal-hold-refusal.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict';

/*
* Does this error count as the refusal of a delete of a version under a legal
* hold?
*
* Two shapes count:
* - HTTP 401 with code `access_denied` ("Access Denied"). This is what real
* B2 answers for b2_delete_file_version on a version under a legal hold,
* measured against real B2 on 2026-11-10 (b2-simulator ledger F-139), and
* what the simulator's b2 profile now answers too.
* - HTTP 400 or 403 with code `file_lock_legal_hold_protected` whose message names the legal
* hold, the
* TypeScript SDK's own simulator profile (not what real B2 answers). Kept
* so the check still passes against that profile and older simulator pins.
*
* Anything else is NOT a refusal for this reason: a bad or expired token
* (401 bad_auth_token / expired_auth_token), a 403 access_denied, a 404, a 5xx,
* a network error.
*/
function isLegalHoldRefusal(error) {
if (!error) return false;
if (error.status === 401 && error.code === 'access_denied') return true;
return (
(error.status === 400 || error.status === 403) &&
error.code === 'file_lock_legal_hold_protected' &&
/legal.?hold/i.test(String(error.message))
);
}

module.exports = { isLegalHoldRefusal };
50 changes: 50 additions & 0 deletions .sdkharness/tests/unit/legal-hold-refusal.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const path = require('node:path');
const test = require('node:test');

const root = path.join(__dirname, '..');
const { isLegalHoldRefusal } = require(path.join(root, 'lib', 'legal-hold-refusal.cjs'));

function b2Error(status, code, message = 'x') {
return Object.assign(new Error(message), { name: 'B2Error', status, code });
}

test('a 401 access_denied is the legal-hold refusal (what real B2 answers)', () => {
assert.equal(isLegalHoldRefusal(b2Error(401, 'access_denied', 'Access Denied')), true);
});

test('the TypeScript SDK simulator profile refusals are still the legal-hold refusal', () => {
assert.equal(
isLegalHoldRefusal(b2Error(400, 'file_lock_legal_hold_protected', 'Legal hold is on')),
true,
);
assert.equal(
isLegalHoldRefusal(b2Error(403, 'file_lock_legal_hold_protected', 'Legal hold is on')),
true,
);
});

test('an unrelated refusal is not the legal-hold refusal', () => {
for (const error of [
b2Error(401, 'bad_auth_token'),
b2Error(401, 'expired_auth_token'),
b2Error(403, 'access_denied'),
b2Error(400, 'bad_request'),
b2Error(404, 'file_not_present'),
b2Error(503, 'service_unavailable'),
new TypeError('fetch failed'),
null,
undefined,
]) {
assert.equal(isLegalHoldRefusal(error), false, String(error?.message));
}
});

test('lock.legal_hold uses the shared refusal rule', () => {
const leaf = readFileSync(path.join(root, 'conformance', 'lock.legal_hold.cjs'), 'utf8');
assert.match(leaf, /isLegalHoldRefusal\(refusal\)/);
assert.match(leaf, /refused for the wrong reason/);
});
Loading