Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
8e0ff6a
feat: add bucket encryption requests
St4NNi Oct 4, 2026
4a571aa
feat: await the vault key check and require twelve characters
St4NNi Oct 4, 2026
8a7eee0
feat: open sealed bucket key copies with shared test vectors
St4NNi Oct 4, 2026
f555721
feat: unlock a bucket with the key from a sealed copy
St4NNi Oct 4, 2026
e75b634
feat: keep bucket encryption state bound to its node and session
St4NNi Oct 4, 2026
323760d
feat: describe bucket lock, recovery and transition states
St4NNi Oct 4, 2026
90f6525
feat: show bucket encryption on its own settings tab
St4NNi Oct 4, 2026
0798cea
feat: unlock, extend and lock a bucket from its settings
St4NNi Oct 4, 2026
4de525c
feat: grant and remove bucket key holders with recovery confirmation
St4NNi Oct 4, 2026
868866e
feat: list bucket key holders and confirm a removal that breaks recovery
St4NNi Oct 4, 2026
cfb4ae1
feat: change the bucket encryption mode and rotate its key
St4NNi Oct 4, 2026
09d56a9
feat: show jobs waiting for a bucket key and restart lock notices
St4NNi Oct 4, 2026
85c4064
test: unlock a vault-locked bucket through the whole browser flow
St4NNi Oct 4, 2026
5c3f666
feat: document encrypted buckets
St4NNi Oct 4, 2026
f383222
chore: keep encryption comments within three lines
St4NNi Oct 4, 2026
3a03287
fix: name bucket unlock lengths in whole units
St4NNi Oct 4, 2026
aa168c1
fix: say that a node-managed bucket unlocks again after a restart
St4NNi Oct 4, 2026
95f93ab
chore: drop the banners of the bucket encryption requests
St4NNi Oct 4, 2026
e37d1fe
fix: clear the secrets HPKE derives once they are used
St4NNi Oct 4, 2026
00faada
fix: keep a bucket request dead after its context changed or ended
St4NNi Oct 4, 2026
9d8e154
fix: treat any server error after an unlock as an unknown outcome
St4NNi Oct 4, 2026
bd6a8c0
fix: call a rewrite finished only without failed or remaining versions
St4NNi Oct 4, 2026
35e7d87
fix: describe a bucket by every key generation it still needs
St4NNi Oct 4, 2026
fe43ed2
fix: unlock and extend every key generation a bucket still needs
St4NNi Oct 4, 2026
8c38363
test: cover key access for active and previous generations
St4NNi Oct 4, 2026
93d7439
fix: keep an unconfirmed unlock and a retry visible when the state re…
St4NNi Oct 4, 2026
98ac079
fix: keep an edited encryption draft and confirm against the shown se…
St4NNi Oct 4, 2026
42744c5
fix: bind key holders to the bucket key and read them after every key…
St4NNi Oct 4, 2026
cfbcc69
fix: show a partial holder list and word a removal whose recovery is …
St4NNi Oct 4, 2026
7c73b1c
fix: show a running or failed key holder search
St4NNi Oct 4, 2026
3c6cb15
feat: wait for a bucket unlock in previews of locked content
St4NNi Oct 4, 2026
617c8b6
fix: show a compute run that waits for a bucket key
St4NNi Oct 4, 2026
30ae55c
test: check IndexedDB and a decrypting bucket in the unlock flow
St4NNi Oct 4, 2026
5cb18f7
fix: document previous bucket keys and locked previews
St4NNi Oct 4, 2026
2d17ebf
fix: name the key generation when extending an unlock
St4NNi Oct 4, 2026
181ca92
fix: drop an action of an older bucket context after its final refresh
St4NNi Oct 4, 2026
803c51f
fix: keep the last bucket state and its drafts through a failed read
St4NNi Oct 4, 2026
564fc1a
fix: remove a key holder only from the holder set the warning showed
St4NNi Oct 4, 2026
c35342b
fix: claim a bucket has no key holders only from a complete list
St4NNi Oct 4, 2026
ddcb13f
fix: say that a bucket lock covers every key generation
St4NNi Oct 4, 2026
00325f5
fix: let only the newest preview load set the preview state
St4NNi Oct 4, 2026
0eac496
fix: check a locked bucket before a direct viewer or a download
St4NNi Oct 4, 2026
317d134
fix: load a locked preview again once its bucket is confirmed plain
St4NNi Oct 4, 2026
0c32e70
fix: promise no node takeover for a run that waits for a bucket key
St4NNi Oct 4, 2026
31fbe1e
fix: drop an opened bucket key when the vault locks before it is sent
St4NNi Oct 4, 2026
323956d
chore: keep the load state comment within three lines
St4NNi Oct 4, 2026
ad42fd3
fix: let a vault lock end every pending bucket key disclosure for good
St4NNi Oct 4, 2026
5d17086
fix: give up a bucket access probe that does not answer in time
St4NNi Oct 4, 2026
122008d
fix: bind preview access checks and downloads to the preview that sta…
St4NNi Oct 4, 2026
9c75777
fix: check for a bucket lock when a media player cannot read its file
St4NNi Oct 4, 2026
fb0a600
fix: name the Check again button and the grants that survive lost adm…
St4NNi Oct 4, 2026
ecb7d65
fix: read a preview again when its bucket, node or session changes
St4NNi Oct 4, 2026
6c835cb
fix: end pending preview work when the preview is disposed
St4NNi Oct 4, 2026
e2a4fde
fix: never let a cancelled or earlier access probe undo a confirmed lock
St4NNi Oct 4, 2026
0400cc6
fix: drop the error of a vault cache read that a newer read overtook
St4NNi Oct 4, 2026
1c3e95b
fix: apply a vault reset only to the session that started it
St4NNi Oct 4, 2026
c626f54
fix: ignore viewer reports about a file the preview no longer shows
St4NNi Oct 4, 2026
9bc7cc0
fix: drop the outcome of a holder change made under an older bucket key
St4NNi Oct 4, 2026
a58364f
fix: report a dropped unlock result without claiming what was sent
St4NNi Oct 4, 2026
29fb311
fix: bind preview reloads and signed URL reuse to the active S3 session
St4NNi Oct 4, 2026
3b3e7e8
fix: keep a confirmed preview lock through later read and download fa…
St4NNi Oct 4, 2026
5b32b35
fix: drop a holder change outcome when the key changes during its ref…
St4NNi Oct 4, 2026
ebc5959
fix: let a failed vault save lock only the session that started it
St4NNi Oct 4, 2026
b899f92
fix: let a vault read that a lock or session change ended leave the v…
St4NNi Oct 4, 2026
8532cf3
fix: remember no key of a new vault once a lock or session change end…
St4NNi Oct 4, 2026
354bdd5
fix: send a vault save only to the account and session it began in
St4NNi Oct 4, 2026
ec35860
fix: end every running vault read and unlock when a reset deletes the…
St4NNi Oct 4, 2026
430bb40
fix: hold the recovery code of a new vault for its session until dism…
St4NNi Oct 4, 2026
49014c5
fix: show the recovery code of new keys until the user stored it
St4NNi Oct 4, 2026
3c4a2cb
fix: drop a held recovery code once the vault carries another recover…
St4NNi Oct 4, 2026
422e7c4
fix: keep the recovery code of a vault whose save answer was lost
St4NNi Oct 4, 2026
818829c
fix: let the read after a vault reset yield to newer vault work
St4NNi Oct 4, 2026
bc10246
fix: link and recheck a locked preview on the S3 issuer node it read …
St4NNi Oct 4, 2026
80c594c
fix: say that new keys were locked when a lock ends their setup after…
St4NNi Oct 4, 2026
9b71fb0
fix: keep a pending recovery code through reads sent before its save …
St4NNi Oct 4, 2026
bbd5eff
fix: say that nothing was saved when a lock comes before the new keys…
St4NNi Oct 4, 2026
29fca05
fix: show a recovery code only while the head with its recovery block…
St4NNi Oct 4, 2026
703eb9a
fix: drop a held recovery code only when no returned head carries its…
St4NNi Oct 4, 2026
d3ef1d1
fix: fence the read after a vault reset across every await of its settle
St4NNi Oct 4, 2026
6fa65df
fix: report a failed read after a vault reset only while no newer wor…
St4NNi Oct 4, 2026
c3ab1f9
fix: name the storage generation in encryption changes and rotations
St4NNi Oct 5, 2026
7001bde
feat: show a notice when a bucket's key recovery is weakened
St4NNi Oct 5, 2026
9ba4ca7
fix: word every refusal of the encryption routes and a refused key ch…
St4NNi Oct 5, 2026
0e492d3
fix: show a node's not_supported answer as a refusal, not an unconfir…
St4NNi Oct 5, 2026
5a61bdd
fix: word the refusal to encrypt a bucket that holds a too large object
St4NNi Oct 5, 2026
95a4b8c
fix: name the size limit when a too large object blocks bucket encryp…
St4NNi Oct 5, 2026
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
8 changes: 7 additions & 1 deletion src/components/compute/TaskDetailPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import CountedList from '@/components/ui/CountedList.vue'
import Pagination from '@/components/ui/Pagination.vue'
import DocsLink from '@/components/ui/DocsLink.vue'
import JobExecutionsTable from '@/components/jobs/JobExecutionsTable.vue'
import JobKeyWait from '@/components/jobs/JobKeyWait.vue'
import JobPlacementFigure from '@/components/jobs/JobPlacementFigure.vue'
import RunLogDialog, { type LogStream } from '@/components/compute/RunLogDialog.vue'
import TaskHeader from '@/components/compute/TaskHeader.vue'
Expand Down Expand Up @@ -299,9 +300,13 @@ interface Summary {
tone: 'error' | 'warning' | 'info' | 'success'
headline: string
}
// TES has no state for a run that waits for a locked bucket; the native job says so.
const awaitingKey = computed(() => nativeJob.value?.state === 'awaiting_key' && !isTerminalTesState(task.value?.state))

const summary = computed<Summary | null>(() => {
const state = task.value?.state
if (!state) return null
if (awaitingKey.value) return { tone: 'warning', headline: 'Waiting for a bucket unlock' }
const duration = runDuration.value
const where = executorKind.value ? ` on ${executorKind.value}` : ''
switch (state) {
Expand Down Expand Up @@ -662,11 +667,12 @@ async function confirmDelete() {
<p v-if="failed && stderrTail" class="truncate font-mono text-[11px]" :title="executorStderr(0)">
stderr: {{ stderrTail }}
</p>
<p v-if="task.state === 'QUEUED'" class="flex flex-wrap items-center gap-2 text-[11px]">
<p v-if="task.state === 'QUEUED' && !awaitingKey" class="flex flex-wrap items-center gap-2 text-[11px]">
One node answers and runs it; another steps in after a while without a response.
<DocsLink topic="compute-run" section="Follow the run" />
</p>
</Notice>
<JobKeyWait v-if="awaitingKey" :waits="nativeJob?.awaiting_keys ?? []" />
<p v-if="lastPollError" class="text-[11px] text-muted-foreground">Refresh failed, retrying.</p>

<section class="space-y-3">
Expand Down
2 changes: 2 additions & 0 deletions src/components/jobs/JobDetailPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import CopyButton from '@/components/ui/CopyButton.vue'
import JobArtifactButton from '@/components/jobs/JobArtifactButton.vue'
import JobAuditTrail from '@/components/jobs/JobAuditTrail.vue'
import JobFamilySection from '@/components/jobs/JobFamilySection.vue'
import JobKeyWait from '@/components/jobs/JobKeyWait.vue'
import JobReportPanel from '@/components/jobs/JobReportPanel.vue'
import JobStateBadge from '@/components/jobs/JobStateBadge.vue'
import { useJobDetail } from '@/composables/useJobs'
Expand Down Expand Up @@ -96,6 +97,7 @@ async function confirmCancel() {
<ErrorPanel v-else-if="loadState === 'error'" :message="loadError || 'This could not be loaded.'" @retry="load" />

<div v-else-if="job" class="space-y-6">
<JobKeyWait v-if="job.state === 'awaiting_key'" :waits="job.awaiting_keys ?? []" />
<section class="space-y-2">
<h3 class="font-display text-sm font-semibold text-aruna-navy">Progress</h3>
<Progress
Expand Down
50 changes: 50 additions & 0 deletions src/components/jobs/JobDistributedDetail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ function taskPanel(getTask: unknown, getJob: unknown, deleteJob: unknown = vi.fn
'@/components/ui/Pagination.vue': moduleDefault(PaginationStub),
'@/components/jobs/JobPlacementFigure.vue': moduleDefault(PlacementFigureStub),
'@/components/jobs/JobExecutionsTable.vue': moduleDefault(ExecutionsTableStub),
'@/components/jobs/JobKeyWait.vue': moduleDefault(
defineComponent({ props: { waits: Array }, setup: (props) => () => h('aside', `waits for ${JSON.stringify(props.waits)}`) }),
),
'@/components/compute/RunLogDialog.vue': moduleDefault(PassThroughStub),
'@/components/compute/TaskHeader.vue': moduleDefault(PassThroughStub),
'@/components/assistant/AskAiButton.vue': moduleDefault(PassThroughStub),
Expand Down Expand Up @@ -328,6 +331,7 @@ function jobPanel(job: JobStatusResponse): Component {
'@/components/jobs/JobFamilySection.vue': moduleDefault(
defineComponent(() => () => h('section', 'native family detail')),
),
'@/components/jobs/JobKeyWait.vue': moduleDefault(defineComponent(() => () => h('aside', 'waits for an unlock'))),
'@/components/jobs/JobReportPanel.vue': moduleDefault(PassThroughStub),
'@/components/jobs/JobStateBadge.vue': moduleDefault(JobStateBadgeStub),
'@/composables/useJobs': {
Expand Down Expand Up @@ -390,6 +394,29 @@ describe('distributed job detail components', () => {
{ destination_key: 'in/config.yaml', bytes: 2048, source_node_id: null, transfer_ms: 0 },
]

it('tells a job that waits for a bucket key apart from a failed one', async () => {
const waiting: JobStatusResponse = {
job_id: '01JJRSTVWXYZ0123456789ABCE',
kind: 'staging',
state: 'awaiting_key',
attempts: 0,
cancel_requested: false,
created_at: '2026-04-09T14:23:11.123+00:00',
updated_at: '2026-04-09T14:31:47.902+00:00',
progress: { current: 0, unit: 'inputs' },
workspace_mode: 'none',
awaiting_keys: [{ node_id: 'node-b', bucket: 'reef' }],
}

const mounted = await mount(jobPanel(waiting), { jobId: waiting.job_id, open: true })

expect(mounted.errors).toEqual([])
expect(content(mounted.root)).toContain('waits for an unlock')
expect(Jobs.JOB_STATE_META.awaiting_key).toEqual({ label: 'Waiting for a key', variant: 'warn' })
expect(Jobs.isTerminalJobState('awaiting_key')).toBe(false)
mounted.app.unmount()
})

it('states no workspace detail, whatever the node reports', async () => {
// A node may still serve a mode and a bucket; a run owns neither any more.
const JobDetailPanel = jobPanel({
Expand Down Expand Up @@ -878,6 +905,29 @@ describe('distributed job detail components', () => {
mounted.app.unmount()
})

it('says a queued run waits for a bucket unlock when its native job does', async () => {
const getTask = vi.fn(async () => ({
id: 'waiting-run',
state: 'QUEUED',
executors: [{ image: 'alpine', command: ['sh'] }],
inputs: [],
outputs: [],
logs: [],
tags: {},
}))
const waits = [{ node_id: 'node-b', bucket: 'reef' }]
const getJob = vi.fn(async () => ({ state: 'awaiting_key', awaiting_keys: waits, family: null }))

const mounted = await mount(taskPanel(getTask, getJob), { taskId: 'waiting-run', open: true })
await vi.waitFor(() => expect(content(mounted.root)).toContain('Waiting for a bucket unlock'))

expect(mounted.errors).toEqual([])
expect(content(mounted.root)).toContain(`waits for ${JSON.stringify(waits)}`)
expect(content(mounted.root)).not.toContain('Queued, waiting for a node')
expect(content(mounted.root)).not.toContain('another steps in')
mounted.app.unmount()
})

it('tells a failure once, with its cause', async () => {
// The badge says Failed; only the stage and the cause line say what failed.
const getTask = vi.fn(async () => ({
Expand Down
41 changes: 41 additions & 0 deletions src/components/jobs/JobKeyWait.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { defineComponent, h } from 'vue'
import * as VueRuntime from 'vue'
import { describe, expect, it } from 'vitest'
import * as Wording from '@/lib/bucketEncryption'
import { compileClientComponent, content, element, mountApp, moduleDefault } from '@/test/clientRender'

const Slotted = (tag: string) =>
defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) })
const LinkStub = defineComponent({
props: { to: Object },
setup: (props, { slots }) => () => h('a', { 'data-to': JSON.stringify(props.to) }, slots.default?.()),
})

const wait = compileClientComponent(new URL('./JobKeyWait.vue', import.meta.url), {
vue: VueRuntime,
'vue-router': { RouterLink: LinkStub },
'@/components/ui/NodeLabel.vue': moduleDefault(defineComponent({ props: { nodeId: String }, setup: (props) => () => h('span', props.nodeId) })),
'@/components/ui/Notice.vue': moduleDefault(Slotted('aside')),
'@/lib/bucketEncryption': Wording,
})

describe('job waiting for a bucket key', () => {
it('links every locked bucket to its Encryption tab on its own node', async () => {
const { root } = await mountApp(wait, { props: { waits: [{ node_id: 'node-b', bucket: 'reef', group_id: 'g-1' }] } })
const link = element(root, (node) => node.tag === 'a')

expect(content(root)).toContain('neither fails nor')
expect(content(root)).toContain('reef on node-b')
expect(JSON.parse(String(link.props['data-to']))).toEqual({
name: 'bucket-storage',
params: { bucketId: 'reef' },
query: { tab: 'encryption', node: 'node-b', group: 'g-1' },
})
})

it('says so when the node named no bucket', async () => {
const { root } = await mountApp(wait, { props: { waits: [] } })

expect(content(root)).toContain('did not say which bucket')
})
})
27 changes: 27 additions & 0 deletions src/components/jobs/JobKeyWait.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script setup lang="ts">
// A job that waits for a locked bucket: not failed and not retried, it goes on
// once a key holder unlocks every bucket named here on its own node.
import { RouterLink } from 'vue-router'
import NodeLabel from '@/components/ui/NodeLabel.vue'
import Notice from '@/components/ui/Notice.vue'
import { bucketUnlockLink } from '@/lib/bucketEncryption'
import type { JobKeyWait } from '@/lib/jobs'

defineProps<{ waits: JobKeyWait[] }>()
</script>

<template>
<Notice tone="warning" title="Waiting for a bucket unlock" data-key-wait>
This job needs data from a locked bucket. It goes on once a key holder unlocks it; until then it neither fails nor
retries.
<ul v-if="waits.length" class="mt-1 list-disc space-y-0.5 pl-4">
<li v-for="wait in waits" :key="`${wait.node_id}/${wait.bucket}`">
<RouterLink :to="bucketUnlockLink(wait.bucket, wait.node_id, wait.group_id)" class="font-medium underline">
{{ wait.bucket }}
</RouterLink>
on <NodeLabel :node-id="wait.node_id" size="sm" />
</li>
</ul>
<p v-else class="mt-1">The node did not say which bucket it waits for.</p>
</Notice>
</template>
2 changes: 1 addition & 1 deletion src/components/jobs/JobsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const {
// Grouped state chips over the loaded list (the tasks-panel treatment).
type StateGroup = 'all' | 'active' | 'done' | 'failed' | 'cancelled'
const GROUP_STATES: Record<Exclude<StateGroup, 'all'>, JobState[]> = {
active: ['queued', 'claimed', 'preparing', 'ready', 'running', 'cancelling'],
active: ['queued', 'awaiting_key', 'claimed', 'preparing', 'ready', 'running', 'cancelling'],
done: ['succeeded'],
failed: ['failed', 'indeterminate'],
cancelled: ['cancelled'],
Expand Down
18 changes: 18 additions & 0 deletions src/components/preview/MediaPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as VueRuntime from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { compileClientComponent, element, mountApp } from '@/test/clientRender'

const media = compileClientComponent(new URL('./MediaPreview.vue', import.meta.url), { vue: VueRuntime })

describe('media preview', () => {
it('reports a failed read of the player', async () => {
for (const mediaKind of ['video', 'audio']) {
const onFailed = vi.fn()
const { root } = await mountApp(media, { props: { url: 'https://b.test/presigned', mediaKind, onFailed } })

;(element(root, (node) => node.tag === mediaKind).props.onError as () => void)()

expect(onFailed).toHaveBeenCalledWith('https://b.test/presigned')
}
})
})
7 changes: 5 additions & 2 deletions src/components/preview/MediaPreview.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
defineProps<{ url: string; mediaKind: 'video' | 'audio'; name?: string }>()
const props = defineProps<{ url: string; mediaKind: 'video' | 'audio'; name?: string }>()
// The player reports which URL it failed to read, so the preview checks only its own file.
const emit = defineEmits<{ (e: 'failed', url: string): void }>()
</script>

<template>
Expand All @@ -10,7 +12,8 @@ defineProps<{ url: string; mediaKind: 'video' | 'audio'; name?: string }>()
controls
preload="metadata"
class="max-h-[68vh] w-full max-w-full rounded"
@error="emit('failed', props.url)"
/>
<audio v-else :src="url" controls preload="metadata" class="w-full" />
<audio v-else :src="url" controls preload="metadata" class="w-full" @error="emit('failed', props.url)" />
</div>
</template>
57 changes: 57 additions & 0 deletions src/components/preview/PdfPreview.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { defineComponent, h } from 'vue'
import * as VueRuntime from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as Errors from '@/composables/s3/errors'
import { compileClientComponent, flush, mountApp, moduleDefault } from '@/test/clientRender'

const Slotted = (tag: string) =>
defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) })

const pdf = compileClientComponent(new URL('./PdfPreview.vue', import.meta.url), {
vue: VueRuntime,
'@lucide/vue': new Proxy({}, { get: () => Slotted('i') }),
'@/composables/s3/errors': Errors,
'@/components/ui/Button.vue': moduleDefault(Slotted('button')),
'@/components/ui/EmptyState.vue': moduleDefault(Slotted('section')),
'@/components/ui/Skeleton.vue': moduleDefault(Slotted('div')),
})

afterEach(() => {
vi.unstubAllGlobals()
})

describe('pdf preview', () => {
it('reports a locked bucket instead of a plain loading failure', async () => {
const headers = { 'x-aruna-bucket-locked': 'true' }
vi.stubGlobal('fetch', vi.fn(async () => new Response('<Error/>', { status: 403, headers })))
const onLocked = vi.fn()

await mountApp(pdf, { props: { url: 'https://b.test/presigned', onLocked } })
await flush()

expect(onLocked).toHaveBeenCalledWith('https://b.test/presigned')
})

it('reports nothing once it is gone before its read answers', async () => {
let answer!: (response: Response) => void
vi.stubGlobal('fetch', vi.fn(() => new Promise<Response>((resolve) => (answer = resolve))))
const onLocked = vi.fn()

const { app } = await mountApp(pdf, { props: { url: 'https://b.test/presigned', onLocked } })
app.unmount()
answer(new Response('<Error/>', { status: 403, headers: { 'x-aruna-bucket-locked': 'true' } }))
await flush()

expect(onLocked).not.toHaveBeenCalled()
})

it('keeps an ordinary refusal a loading failure', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('<Error/>', { status: 403 })))
const onLocked = vi.fn()

await mountApp(pdf, { props: { url: 'https://b.test/presigned', onLocked } })
await flush()

expect(onLocked).not.toHaveBeenCalled()
})
})
19 changes: 16 additions & 3 deletions src/components/preview/PdfPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
// api/src/csp.rs serves `frame-src blob:`); the bytes are fetched over the
// already-allowed connect-src path, so the iframe never touches the S3 origin.
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { BUCKET_LOCKED_HEADER } from '@/composables/s3/errors'
import Button from '@/components/ui/Button.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import { ExternalLink, FileText } from '@lucide/vue'

const props = defineProps<{ url: string; name?: string }>()
/** `locked` names the URL whose read a locked bucket refused. */
const emit = defineEmits<{ (e: 'locked', url: string): void }>()
// A viewer that is gone neither reports nor keeps what its read returns.
const reading = new AbortController()

const blobUrl = ref<string | null>(null)
const failed = ref(false)
Expand All @@ -19,19 +24,27 @@ function openTab() {
}

onMounted(async () => {
const url = props.url
try {
const response = await fetch(props.url)
const response = await fetch(url, { signal: reading.signal })
if (reading.signal.aborted) return
if (response.status === 403 && response.headers.get(BUCKET_LOCKED_HEADER) === 'true') {
emit('locked', url)
return
}
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const blob = await response.blob()
if (reading.signal.aborted) return
blobUrl.value = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' }))
} catch {
failed.value = true
if (!reading.signal.aborted) failed.value = true
} finally {
loading.value = false
if (!reading.signal.aborted) loading.value = false
}
})

onBeforeUnmount(() => {
reading.abort()
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
})
</script>
Expand Down
Loading
Loading