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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **B2Simulator create-key validation now matches more B2 constraints.** `b2_create_key`
rejects unknown or empty capability lists, key names outside B2's 1..100
letters/digits/hyphen grammar, child keys that exceed the creator token's
capabilities, bucket scope, or name prefix, and mismatched request `accountId`
values instead of storing over-broad simulator keys. The implicit master
credential now advertises the full `Capability` enum so create-key grant
checks use the same ceiling clients see in `allowed.capabilities`.
- **Real-B2 integration evidence is explicit and diagnosable.** Same-repo
integration workflow runs now fail when required B2 secrets are missing instead
of silently accepting an all-skipped suite, integration setup logs per-step
Expand Down
6 changes: 6 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,12 @@ describe('B2Client.hasCapabilities', () => {
})

it('returns ok: false with the missing list when capabilities are absent', () => {
const auth = client.accountInfo.getAuth()
if (auth === null) throw new Error('expected authorized client')
;(auth.apiInfo.storageApi.allowed as { capabilities: readonly Capability[] }).capabilities = [
Capability.ListBuckets,
]

const result = client.hasCapabilities([Capability.ListBuckets, Capability.BypassGovernance])
expect(result.ok).toBe(false)
expect(result.missing).toEqual([Capability.BypassGovernance])
Expand Down
272 changes: 268 additions & 4 deletions src/simulator/fidelity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type B2Simulator,
DOWNLOAD_AUTH_DURATION_MAX_SECONDS,
DOWNLOAD_AUTH_DURATION_MIN_SECONDS,
KEY_NAME_MAX,
} from './index.ts'

/**
Expand Down Expand Up @@ -531,7 +532,7 @@ describe('B2Simulator hooks: onWebhookDeliver', () => {
// Strict-auth mode
// ---------------------------------------------------------------------------

describe('B2Simulator strictAuth: capability enforcement', () => {
describe('B2Simulator capability enforcement', () => {
async function authorizeWithKey(
sim: B2Simulator,
key: { applicationKeyId: string; applicationKey: string },
Expand All @@ -558,6 +559,65 @@ describe('B2Simulator strictAuth: capability enforcement', () => {
).downloadAuthorizationTokens.size
}

let seededKeyCounter = 0

function seedApplicationKey(
sim: B2Simulator,
options: {
capabilities: readonly Capability[]
bucketIds?: readonly string[] | null
namePrefix?: string | null
},
): { applicationKeyId: string; applicationKey: string } {
seededKeyCounter += 1
const applicationKeyId = `seed-key-${seededKeyCounter}`
const applicationKey = `seed-secret-${seededKeyCounter}`
const keys = (
sim as unknown as {
readonly keys: Map<
string,
{
readonly applicationKeyId: string
readonly keyName: string
readonly capabilities: readonly Capability[]
readonly accountId: string
readonly applicationKey: string
readonly bucketIds: readonly string[] | null
readonly namePrefix: string | null
readonly expirationTimestamp: number | null
}
>
}
).keys
keys.set(applicationKeyId, {
applicationKeyId,
keyName: `seed-key-${seededKeyCounter}`,
capabilities: options.capabilities,
accountId: 'sim_account_0001',
applicationKey,
bucketIds: options.bucketIds ?? null,
namePrefix: options.namePrefix ?? null,
expirationTimestamp: null,
})
return { applicationKeyId, applicationKey }
}

async function sendCreateKey(
sim: B2Simulator,
authToken: string,
body: Record<string, unknown>,
): Promise<HttpResponse> {
return await sim.transport().send({
method: 'POST',
url: 'http://localhost:0/b2api/v4/b2_create_key',
headers: {
Authorization: authToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({ accountId: 'sim_account_0001', ...body }),
})
}

it('grants the master credential the documented capability set by default', async () => {
const { client } = makeClient({ sim: { strictAuth: true } })
await client.authorize()
Expand All @@ -566,9 +626,8 @@ describe('B2Simulator strictAuth: capability enforcement', () => {
expect(allowed?.capabilities).toContain(Capability.WriteFiles)
expect(allowed?.capabilities).toContain(Capability.ListBuckets)
expect(allowed?.buckets).toBeNull()
// Master does NOT have BypassGovernance — tests that need it must
// mint a key with that cap explicitly.
expect(allowed?.capabilities).not.toContain(Capability.BypassGovernance)
expect(allowed?.capabilities).toContain(Capability.BypassGovernance)
expect(allowed?.capabilities).toContain(Capability.WriteFileRetentions)
})

it('rejects with 401 when the auth token is unknown', async () => {
Expand Down Expand Up @@ -613,6 +672,211 @@ describe('B2Simulator strictAuth: capability enforcement', () => {
expect(expiredBody.code).toBe('expired_auth_token')
})

it('rejects malformed create-key inputs in default mode', async () => {
const { client, sim } = makeClient()
await client.authorize()
const authToken = client.accountInfo.getAuthToken()
const maxLengthName = 'a'.repeat(KEY_NAME_MAX)

for (const { body, message } of [
{
body: {
capabilities: [Capability.ListBuckets, 'notARealCapability'],
keyName: 'unknown-capability-key',
},
message: 'unknown capability',
},
{
body: { capabilities: [Capability.ListBuckets], keyName: 'bad\nname' },
message: 'letters, digits, and hyphens',
},
{
body: { capabilities: [], keyName: 'empty-capability-key' },
message: 'non-empty array',
},
{
body: {
capabilities: [Capability.ReadFiles],
keyName: 'bad-bucketids-key',
bucketIds: 'x',
},
message: 'bucketIds',
},
{
body: { capabilities: [Capability.ReadFiles], keyName: 'bad-prefix-key', namePrefix: 1 },
message: 'namePrefix',
},
{
body: {
capabilities: [Capability.ListBuckets],
keyName: 'bad-duration-key',
validDurationInSeconds: '60',
},
message: 'validDurationInSeconds',
},
{
body: { capabilities: [Capability.ListBuckets], keyName: '' },
message: 'keyName',
},
{
body: { capabilities: [Capability.ListBuckets], keyName: `${maxLengthName}a` },
message: 'keyName',
},
]) {
const resp = await sendCreateKey(sim, authToken, body)
expect(resp.status).toBe(400)
await expect(resp.json()).resolves.toMatchObject({
code: 'bad_request',
message: expect.stringContaining(message),
})
}

await expect(
client.createKey({ capabilities: [Capability.ListBuckets], keyName: maxLengthName }),
).resolves.toMatchObject({ keyName: maxLengthName })
})

it('uses the advertised master capabilities as the create-key grant ceiling', async () => {
const { client } = makeClient({ sim: { strictAuth: true } })
await client.authorize()
const allowed = client.accountInfo.getAuth()?.apiInfo.storageApi.allowed.capabilities

expect(allowed).toContain(Capability.BypassGovernance)
expect(allowed).toContain(Capability.WriteFileRetentions)
await expect(
client.createKey({
capabilities: [Capability.BypassGovernance, Capability.WriteFileRetentions],
keyName: 'master-lock-grant',
}),
).resolves.toMatchObject({
capabilities: expect.arrayContaining([
Capability.BypassGovernance,
Capability.WriteFileRetentions,
]),
})
})

it('rejects create-key accountId values outside the authenticated account', async () => {
const { client, sim } = makeClient()
await client.authorize()
const resp = await sendCreateKey(sim, client.accountInfo.getAuthToken(), {
accountId: 'sim_account_9999',
capabilities: [Capability.ListBuckets],
keyName: 'wrong-account-key',
})

expect(resp.status).toBe(400)
await expect(resp.json()).resolves.toMatchObject({
code: 'bad_request',
message: expect.stringContaining('accountId'),
})
})

it('uses the simulator clock for create-key expiration timestamps', async () => {
const { client, sim } = makeClient()
await client.authorize()
const beforeAdvance = Date.now()
sim.advanceTime(60_000)

const key = await client.createKey({
capabilities: [Capability.ListBuckets],
keyName: 'clock-expiring-key',
validDurationInSeconds: 60,
})

if (key.expirationTimestamp === null) {
throw new Error('expected an expiring application key')
}
expect(key.expirationTimestamp).toBeGreaterThanOrEqual(beforeAdvance + 120_000)
})

it.each([
false,
true,
])('rejects create-key capabilities outside the creator grant (strictAuth=%s)', async (strictAuth) => {
const { client, sim } = strictAuth ? makeClient({ sim: { strictAuth: true } }) : makeClient()
await client.authorize()
const creatorKey = await client.createKey({
capabilities: [Capability.WriteKeys],
keyName: strictAuth ? 'limited-key-admin-strict' : 'limited-key-admin-default',
})
const creatorClient = await authorizeWithKey(sim, creatorKey)

await expect(
creatorClient.createKey({
capabilities: [Capability.ReadFiles],
keyName: strictAuth ? 'too-wide-child-strict' : 'too-wide-child-default',
}),
).rejects.toThrow(/exceed creator grant/)
await expect(
creatorClient.createKey({
capabilities: [Capability.WriteKeys],
keyName: strictAuth ? 'allowed-child-strict' : 'allowed-child-default',
}),
).resolves.toMatchObject({
keyName: strictAuth ? 'allowed-child-strict' : 'allowed-child-default',
})
})

it.each([
false,
true,
])('rejects create-key scope outside the creator grant (strictAuth=%s)', async (strictAuth) => {
const { client, sim } = strictAuth ? makeClient({ sim: { strictAuth: true } }) : makeClient()
await client.authorize()
const allowedBucket = await client.createBucket({
bucketName: strictAuth ? 'scope-allowed-strict' : 'scope-allowed-default',
bucketType: BucketType.AllPrivate,
})
const blockedBucket = await client.createBucket({
bucketName: strictAuth ? 'scope-blocked-strict' : 'scope-blocked-default',
bucketType: BucketType.AllPrivate,
})
const creatorKey = seedApplicationKey(sim, {
capabilities: [Capability.WriteKeys, Capability.ReadFiles],
bucketIds: [allowedBucket.id],
namePrefix: 'tenant/',
})
const creatorClient = await authorizeWithKey(sim, creatorKey)
const keyNamePrefix = strictAuth ? 'strict' : 'default'

await expect(
creatorClient.createKey({
capabilities: [Capability.ReadFiles],
keyName: `${keyNamePrefix}-all-buckets-child`,
bucketIds: null,
namePrefix: 'tenant/',
}),
).rejects.toThrow(/bucket scope/)
await expect(
creatorClient.createKey({
capabilities: [Capability.ReadFiles],
keyName: `${keyNamePrefix}-blocked-bucket-child`,
bucketIds: [blockedBucket.id],
namePrefix: 'tenant/',
}),
).rejects.toThrow(/bucket scope/)
await expect(
creatorClient.createKey({
capabilities: [Capability.ReadFiles],
keyName: `${keyNamePrefix}-wide-prefix-child`,
bucketIds: [allowedBucket.id],
namePrefix: 'other/',
}),
).rejects.toThrow(/namePrefix/)
await expect(
creatorClient.createKey({
capabilities: [Capability.ReadFiles],
keyName: `${keyNamePrefix}-narrow-prefix-child`,
bucketIds: [allowedBucket.id],
namePrefix: 'tenant/narrow/',
}),
).resolves.toMatchObject({
bucketIds: [allowedBucket.id],
namePrefix: 'tenant/narrow/',
})
})

it('enforces single-bucket application key scope from the bucketId alias', async () => {
const { client, sim } = makeClient({ sim: { strictAuth: true } })
await client.authorize()
Expand Down
Loading