[codex] Add admin storage connection testing (#475)

* feat(storage): add admin connection testing

Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0

* fix: correct storage CORS guidance

Agent-Profile: https://agent-kanban.dev/agents/2673e70e0085f4e0

---------

Co-authored-by: Jordan Park <jordan-park@mails.agent-kanban.dev>
This commit is contained in:
agent-kanban-local[bot]
2026-06-24 00:37:59 -04:00
committed by GitHub
parent f4b65e4987
commit c7f3d11793
18 changed files with 773 additions and 35 deletions
+1 -1
View File
@@ -637,7 +637,7 @@ func (c *Client) CompleteObjectUpload(ctx context.Context, token string, id stri
}
func (c *Client) AbortObjectUploadSession(ctx context.Context, token string, id string, sessionID string) error {
res, err := c.api.AbortObjectUploadWithResponse(ctx, id, sessionID, bearer(token))
res, err := c.api.AbortObjectUploadWithResponse(ctx, id, sessionID, nil, bearer(token))
if err != nil {
return err
}
+71 -9
View File
@@ -876,6 +876,24 @@ func (e TransferObjectJSONBodyMode) Valid() bool {
}
}
// Defines values for AbortObjectUploadParamsStrictStorageCleanup.
const (
AbortObjectUploadParamsStrictStorageCleanupN1 AbortObjectUploadParamsStrictStorageCleanup = "1"
AbortObjectUploadParamsStrictStorageCleanupTrue AbortObjectUploadParamsStrictStorageCleanup = "true"
)
// Valid indicates whether the value is a known member of the AbortObjectUploadParamsStrictStorageCleanup enum.
func (e AbortObjectUploadParamsStrictStorageCleanup) Valid() bool {
switch e {
case AbortObjectUploadParamsStrictStorageCleanupN1:
return true
case AbortObjectUploadParamsStrictStorageCleanupTrue:
return true
default:
return false
}
}
// Defines values for ListSharesParamsStatus.
const (
ListSharesParamsStatusActive ListSharesParamsStatus = "active"
@@ -1172,13 +1190,13 @@ func (e CreateTeamInviteLinkJSONBodyRole) Valid() bool {
// Defines values for JoinTeam200JSONResponseBodyOk.
const (
JoinTeam200JSONResponseBodyOkTrue JoinTeam200JSONResponseBodyOk = true
True JoinTeam200JSONResponseBodyOk = true
)
// Valid indicates whether the value is a known member of the JoinTeam200JSONResponseBodyOk enum.
func (e JoinTeam200JSONResponseBodyOk) Valid() bool {
switch e {
case JoinTeam200JSONResponseBodyOkTrue:
case True:
return true
default:
return false
@@ -3216,6 +3234,7 @@ type CreateObjectJSONBody struct {
OnConflict *CreateObjectJSONBodyOnConflict `json:"onConflict,omitempty"`
Parent *string `json:"parent,omitempty"`
Size *int `json:"size,omitempty"`
StorageId *string `json:"storageId,omitempty"`
Type string `json:"type"`
}
@@ -3251,6 +3270,14 @@ type TransferObjectJSONBody struct {
// TransferObjectJSONBodyMode defines parameters for TransferObject.
type TransferObjectJSONBodyMode string
// AbortObjectUploadParams defines parameters for AbortObjectUpload.
type AbortObjectUploadParams struct {
StrictStorageCleanup *AbortObjectUploadParamsStrictStorageCleanup `form:"strictStorageCleanup,omitempty" json:"strictStorageCleanup,omitempty"`
}
// AbortObjectUploadParamsStrictStorageCleanup defines parameters for AbortObjectUpload.
type AbortObjectUploadParamsStrictStorageCleanup string
// CompleteObjectUploadJSONBody defines parameters for CompleteObjectUpload.
type CompleteObjectUploadJSONBody struct {
Parts []struct {
@@ -4648,7 +4675,7 @@ type ClientInterface interface {
TransferObject(ctx context.Context, id string, body TransferObjectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// AbortObjectUpload request
AbortObjectUpload(ctx context.Context, id string, uploadSessionId string, reqEditors ...RequestEditorFn) (*http.Response, error)
AbortObjectUpload(ctx context.Context, id string, uploadSessionId string, params *AbortObjectUploadParams, reqEditors ...RequestEditorFn) (*http.Response, error)
// CompleteObjectUploadWithBody request with any body
CompleteObjectUploadWithBody(ctx context.Context, id string, uploadSessionId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -7290,8 +7317,8 @@ func (c *Client) TransferObject(ctx context.Context, id string, body TransferObj
return c.Client.Do(req)
}
func (c *Client) AbortObjectUpload(ctx context.Context, id string, uploadSessionId string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewAbortObjectUploadRequest(c.Server, id, uploadSessionId)
func (c *Client) AbortObjectUpload(ctx context.Context, id string, uploadSessionId string, params *AbortObjectUploadParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewAbortObjectUploadRequest(c.Server, id, uploadSessionId, params)
if err != nil {
return nil, err
}
@@ -13946,7 +13973,7 @@ func NewTransferObjectRequestWithBody(server string, id string, contentType stri
}
// NewAbortObjectUploadRequest generates requests for AbortObjectUpload
func NewAbortObjectUploadRequest(server string, id string, uploadSessionId string) (*http.Request, error) {
func NewAbortObjectUploadRequest(server string, id string, uploadSessionId string, params *AbortObjectUploadParams) (*http.Request, error) {
var err error
var pathParam0 string
@@ -13978,6 +14005,33 @@ func NewAbortObjectUploadRequest(server string, id string, uploadSessionId strin
return nil, err
}
if params != nil {
// queryValues collects non-styled parameters (passthrough, JSON)
// that are safe to round-trip through url.Values.Encode().
queryValues := queryURL.Query()
// rawQueryFragments collects pre-encoded query fragments from
// styled parameters, preserving literal commas as delimiters
// per the OpenAPI spec (e.g. "color=blue,black,brown").
var rawQueryFragments []string
if params.StrictStorageCleanup != nil {
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "strictStorageCleanup", *params.StrictStorageCleanup, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
return nil, err
} else {
for _, qp := range strings.Split(queryFrag, "&") {
rawQueryFragments = append(rawQueryFragments, qp)
}
}
}
if encoded := queryValues.Encode(); encoded != "" {
rawQueryFragments = append(rawQueryFragments, encoded)
}
queryURL.RawQuery = strings.Join(rawQueryFragments, "&")
}
req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil)
if err != nil {
return nil, err
@@ -18167,7 +18221,7 @@ type ClientWithResponsesInterface interface {
TransferObjectWithResponse(ctx context.Context, id string, body TransferObjectJSONRequestBody, reqEditors ...RequestEditorFn) (*TransferObjectResponse, error)
// AbortObjectUploadWithResponse request
AbortObjectUploadWithResponse(ctx context.Context, id string, uploadSessionId string, reqEditors ...RequestEditorFn) (*AbortObjectUploadResponse, error)
AbortObjectUploadWithResponse(ctx context.Context, id string, uploadSessionId string, params *AbortObjectUploadParams, reqEditors ...RequestEditorFn) (*AbortObjectUploadResponse, error)
// CompleteObjectUploadWithBodyWithResponse request with any body
CompleteObjectUploadWithBodyWithResponse(ctx context.Context, id string, uploadSessionId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CompleteObjectUploadResponse, error)
@@ -24270,6 +24324,7 @@ type AbortObjectUploadResponse struct {
JSON400 *Error
JSON403 *Error
JSON404 *Error
JSON502 *Error
}
// Status returns HTTPResponse.Status
@@ -28792,8 +28847,8 @@ func (c *ClientWithResponses) TransferObjectWithResponse(ctx context.Context, id
}
// AbortObjectUploadWithResponse request returning *AbortObjectUploadResponse
func (c *ClientWithResponses) AbortObjectUploadWithResponse(ctx context.Context, id string, uploadSessionId string, reqEditors ...RequestEditorFn) (*AbortObjectUploadResponse, error) {
rsp, err := c.AbortObjectUpload(ctx, id, uploadSessionId, reqEditors...)
func (c *ClientWithResponses) AbortObjectUploadWithResponse(ctx context.Context, id string, uploadSessionId string, params *AbortObjectUploadParams, reqEditors ...RequestEditorFn) (*AbortObjectUploadResponse, error) {
rsp, err := c.AbortObjectUpload(ctx, id, uploadSessionId, params, reqEditors...)
if err != nil {
return nil, err
}
@@ -38529,6 +38584,13 @@ func ParseAbortObjectUploadResponse(rsp *http.Response) (*AbortObjectUploadRespo
}
response.JSON404 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON502 = &dest
}
return response, nil
@@ -241,10 +241,10 @@ describe('getStorage', () => {
describe('selectStorage', () => {
async function seedActive(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
opts: { capacity?: number; used?: number; status?: string } = {},
opts: { capacity?: number; used?: number; status?: string; title?: string } = {},
) {
return createStorageRepo(db).create({
title: 'Seed',
const created = await createStorageRepo(db).create({
title: opts.title ?? 'Seed',
bucket: 'b',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
@@ -252,13 +252,43 @@ describe('selectStorage', () => {
secretKey: 'S',
capacity: opts.capacity ?? 0,
})
if (opts.used !== undefined || opts.status !== undefined) {
await db.run(
sql`UPDATE storages SET used = ${opts.used ?? created.used}, status = ${opts.status ?? created.status} WHERE id = ${created.id}`,
)
}
return createStorageRepo(db).get(created.id)
}
it('returns an active storage with unlimited capacity', async () => {
const { db } = await createTestApp()
const created = await seedActive(db)
const found = await createStorageRepo(db).select()
expect(found.id).toBe(created.id)
expect(found.id).toBe(created?.id)
})
it('returns the requested active storage with capacity even when it is not oldest', async () => {
const { db } = await createTestApp()
const first = await seedActive(db, { title: 'First' })
const second = await seedActive(db, { title: 'Second' })
const auto = await createStorageRepo(db).select()
const targeted = await createStorageRepo(db).select(second?.id)
expect(auto.id).toBe(first?.id)
expect(targeted.id).toBe(second?.id)
})
it('rejects a requested inactive storage', async () => {
const { db } = await createTestApp()
const created = await seedActive(db, { status: 'disabled' })
await expect(createStorageRepo(db).select(created?.id)).rejects.toThrow('No available storage')
})
it('rejects a requested full storage', async () => {
const { db } = await createTestApp()
const created = await seedActive(db, { capacity: 10, used: 10 })
await expect(createStorageRepo(db).select(created?.id)).rejects.toThrow('No available storage')
})
it('throws when no active storage exists', async () => {
+8 -2
View File
@@ -95,11 +95,17 @@ export function createStorageRepo(db: Database): StorageRepo {
return 'ok'
},
async select() {
async select(id) {
const rows = await db
.select()
.from(storages)
.where(and(eq(storages.status, 'active'), or(eq(storages.capacity, 0), lt(storages.used, storages.capacity))))
.where(
and(
id ? eq(storages.id, id) : undefined,
eq(storages.status, 'active'),
or(eq(storages.capacity, 0), lt(storages.used, storages.capacity)),
),
)
.orderBy(asc(storages.createdAt))
.limit(1)
+71 -3
View File
@@ -97,9 +97,13 @@ const validStorage = {
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
}
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db'], opts: { metered?: boolean } = {}) {
async function insertStorage(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
opts: { id?: string; metered?: boolean; capacity?: number; used?: number; status?: string } = {},
) {
const now = Date.now()
const metered = opts.metered ? 1 : 0
const id = opts.id ?? validStorage.id
await db.run(sql`
INSERT INTO storages (
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
@@ -107,9 +111,9 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
egress_credit_per_unit, created_at, updated_at
)
VALUES (
${validStorage.id}, ${validStorage.title}, ${validStorage.bucket},
${id}, ${validStorage.title}, ${validStorage.bucket},
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey},
'', '', 0, 0, 'active', ${metered}, ${100 * 1024 ** 2}, 1, ${now}, ${now}
'', '', ${opts.capacity ?? 0}, ${opts.used ?? 0}, ${opts.status ?? 'active'}, ${metered}, ${100 * 1024 ** 2}, 1, ${now}, ${now}
)
`)
}
@@ -675,6 +679,70 @@ describe('Objects API', () => {
expect(body.upload.urls).toEqual(['https://presigned-upload.example.com'])
})
it('POST /api/objects with storageId uses that exact eligible storage', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await insertStorage(db, { id: 'st-oldest' })
await insertStorage(db, { id: 'st-target' })
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'target.txt', type: 'text/plain', size: 1, storageId: 'st-target' }),
})
expect(res.status).toBe(201)
const body = (await res.json()) as { id: string; storageId: string }
expect(body.storageId).toBe('st-target')
const rows = await db.all<{ storageId: string }>(
sql`SELECT storage_id as storageId FROM matters WHERE id = ${body.id}`,
)
expect(rows[0].storageId).toBe('st-target')
})
it('POST /api/objects with ineligible storageId fails before draft/session creation', async () => {
for (const storage of [
{ id: 'missing' },
{ id: 'inactive', status: 'disabled' },
{ id: 'full', capacity: 1, used: 1 },
]) {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
if (storage.id !== 'missing') await insertStorage(db, storage)
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: `${storage.id}.txt`, type: 'text/plain', size: 1, storageId: storage.id }),
})
expect(res.status).toBe(503)
const matters = await db.all<{ count: number }>(sql`SELECT COUNT(*) as count FROM matters`)
const sessions = await db.all<{ count: number }>(sql`SELECT COUNT(*) as count FROM object_upload_sessions`)
expect(matters[0].count).toBe(0)
expect(sessions[0].count).toBe(0)
}
})
it('POST /api/objects with storageId is admin-only and fails before draft/session creation', async () => {
const { app, db } = await createTestApp()
await adminHeaders(app)
const headers = await authedHeaders(app, 'editor@example.com')
await insertStorage(db, { id: 'st-target' })
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'target.txt', type: 'text/plain', size: 1, storageId: 'st-target' }),
})
expect(res.status).toBe(403)
const matters = await db.all<{ count: number }>(sql`SELECT COUNT(*) as count FROM matters`)
const sessions = await db.all<{ count: number }>(sql`SELECT COUNT(*) as count FROM object_upload_sessions`)
expect(matters[0].count).toBe(0)
expect(sessions[0].count).toBe(0)
})
it('POST /api/objects rejects a file larger than 5 TiB [spec: objects/create-file-too-large]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
+10 -2
View File
@@ -112,6 +112,9 @@ const listObjectsQuerySchema = pageQuerySchema.extend({
const idParam = z.object({ id: z.string() })
const sessionParams = z.object({ id: z.string(), uploadSessionId: z.string() })
const abortUploadQuerySchema = z.object({
strictStorageCleanup: z.enum(['1', 'true']).optional(),
})
// The caller acting on objects: a download-task-upload token acts on behalf of
// the task creator; otherwise it is the authenticated user.
@@ -225,12 +228,13 @@ const abortUploadRoute = createRoute({
method: 'delete',
path: '/{id}/uploads/{uploadSessionId}',
middleware: [requireObjectWriteAccess] as const,
request: { params: sessionParams },
request: { params: sessionParams, query: abortUploadQuerySchema },
responses: {
204: { description: 'Aborted upload and discarded the draft' },
400: errorResponse('Invalid upload session'),
403: errorResponse('Forbidden'),
404: errorResponse('Not found'),
502: errorResponse('Storage cleanup failed'),
},
})
@@ -361,7 +365,10 @@ const objects = app
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const result = await createObject(c.get('deps'), { orgId, actor: objectActor(c), input: c.req.valid('json') })
const input = c.req.valid('json')
if (input.storageId && c.get('userRole') !== 'admin') throw forbidden('Forbidden')
const result = await createObject(c.get('deps'), { orgId, actor: objectActor(c), input })
if (!result.ok) throw result.error
if ('upload' in result) return c.json({ ...toMatterDTO(result.matter), upload: result.upload }, 201)
return c.json(toMatterDTO(result.matter), 201)
@@ -419,6 +426,7 @@ const objects = app
objectId: c.req.valid('param').id,
sessionId: c.req.valid('param').uploadSessionId,
actorId: actorId(c),
strictStorageCleanup: c.req.valid('query').strictStorageCleanup !== undefined,
})
return c.body(null, 204)
})
+112
View File
@@ -304,6 +304,73 @@ describe('object usecase', () => {
}
})
it('uses an eligible requested storage for a file draft', async () => {
const target = { ...storage, id: 'st-target' } as StorageRecord
const select = vi.fn(async () => target)
const create = vi.fn(async (input: Parameters<MatterRepo['create']>[0]) => file('d1', input as Partial<Matter>))
const createSession = vi.fn(
async (input: Parameters<ObjectUploadSessionRepo['create']>[0]) =>
({
id: 'sess-1',
objectId: input.objectId,
uploadId: input.uploadId,
partSize: input.partSize,
status: 'active',
storageKey: input.storageKey,
expiresAt: new Date(Date.now() + 3_600_000),
createdAt: new Date(),
updatedAt: new Date(),
}) as ObjectUploadSessionRecord,
)
const { deps } = makeDeps({
storages: { select },
matter: { create },
objectUploadSessions: { create: createSession },
})
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: {
name: 'photo.jpg',
type: 'image/jpeg',
size: 1,
dirtype: DirType.FILE,
parent: '',
storageId: 'st-target',
},
})
expect(out.ok).toBe(true)
expect(select).toHaveBeenCalledWith('st-target')
expect(create).toHaveBeenCalledWith(expect.objectContaining({ storageId: 'st-target' }))
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({ storageId: 'st-target' }))
})
it('rejects a requested missing, inactive, or full storage before creating rows', async () => {
const create = vi.fn()
const createSession = vi.fn()
const { deps } = makeDeps({
matter: { create },
objectUploadSessions: { create: createSession },
storages: {
select: async () => {
throw new Error('No available storage')
},
},
})
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'x.txt', type: 'text/plain', dirtype: DirType.FILE, parent: '', storageId: 'missing' },
})
expectError(out, 503, 'Storage is not active or has no available capacity', 'NO_STORAGE_CONFIGURED')
expect(create).not.toHaveBeenCalled()
expect(createSession).not.toHaveBeenCalled()
})
it('creates a large file draft and returns multipart upload instructions', async () => {
const fiveGiB = 5 * 1024 * 1024 * 1024
const size = fiveGiB + 1 // just over 5 GiB → two 5 GiB parts
@@ -363,6 +430,26 @@ describe('object usecase', () => {
expectError(out, 503, 'No storage configured', 'NO_STORAGE_CONFIGURED')
})
it('does not allow download-task uploads to choose a storage', async () => {
const create = vi.fn()
const select = vi.fn()
const { deps } = makeDeps({ matter: { create }, storages: { select } })
const out = await createObject(deps, {
orgId: 'o1',
actor: {
kind: 'download-task-upload',
downloaderId: 'd1',
taskId: 't1',
targetFolder: 'Inbox',
createdByUserId: 'creator',
},
input: { name: 'x.txt', type: 'text/plain', dirtype: DirType.FILE, parent: 'Inbox', storageId: 'st-1' },
})
expectError(out, 403, 'Storage selection is not allowed for task uploads')
expect(select).not.toHaveBeenCalled()
expect(create).not.toHaveBeenCalled()
})
it('rejects an agent upload outside its target folder', async () => {
const create = vi.fn()
const { deps } = makeDeps({ matter: { create } })
@@ -575,6 +662,31 @@ describe('object usecase', () => {
expect(cancelDraft).toHaveBeenCalledWith('d1', 'o1', 'u1')
})
it('strict single-PUT abort fails when S3 cleanup fails', async () => {
const setStatus = vi.fn()
const cancelDraft = vi.fn()
const deleteObjectFn = vi.fn(async () => {
throw new Error('delete denied')
})
const { deps } = makeDeps({
matter: { get: async () => file('d1', { status: 'draft' }), cancelDraft },
s3: { deleteObject: deleteObjectFn },
objectUploadSessions: { get: async () => session(), setStatus },
})
await expect(
abortUpload(deps, {
orgId: 'o1',
objectId: 'd1',
sessionId: 'sess-1',
actorId: 'u1',
strictStorageCleanup: true,
}),
).rejects.toMatchObject({ name: 'ObjectUploadSessionError', code: 'storage_failure' })
expect(setStatus).not.toHaveBeenCalled()
expect(cancelDraft).not.toHaveBeenCalled()
})
it('aborts a multipart session via AbortMultipartUpload', async () => {
const abortMultipartUpload = vi.fn(async () => {})
const { deps } = makeDeps({
+13 -5
View File
@@ -162,6 +162,9 @@ export async function createObject(
const size = input.size ?? 0
if (actor.kind === 'download-task-upload') {
if (input.storageId) {
return { ok: false, error: forbidden('Storage selection is not allowed for task uploads') }
}
if (!isWithinDownloadTarget(parent, actor.targetFolder)) {
return { ok: false, error: forbidden('Target folder is outside task authorization') }
}
@@ -175,10 +178,13 @@ export async function createObject(
let storage: StorageRecord
try {
storage = await deps.storages.select()
storage = await deps.storages.select(input.storageId)
} catch (error) {
if (error instanceof Error && error.message === 'No available storage') {
return { ok: false, error: noStorage() }
return {
ok: false,
error: input.storageId ? noStorage('Storage is not active or has no available capacity') : noStorage(),
}
}
throw error
}
@@ -383,7 +389,7 @@ export async function completeUpload(
// already-aborted session; rejects completing one.
export async function abortUpload(
deps: Pick<Deps, 'matter' | 'storages' | 's3' | 'objectUploadSessions'>,
params: { orgId: string; objectId: string; sessionId: string; actorId: string },
params: { orgId: string; objectId: string; sessionId: string; actorId: string; strictStorageCleanup?: boolean },
): Promise<void> {
const { storage } = await loadObjectForUploadSession(deps, params.orgId, params.objectId)
const record = await deps.objectUploadSessions.get(params.orgId, params.objectId, params.sessionId)
@@ -405,8 +411,10 @@ export async function abortUpload(
// bytes reach S3.
try {
await deps.s3.deleteObject(storage, record.storageKey)
} catch {
// ignore
} catch (error) {
if (params.strictStorageCleanup) {
throw new ObjectUploadSessionError('storage_failure', `Storage cleanup failed: ${(error as Error).message}`)
}
}
}
await deps.objectUploadSessions.setStatus(record.id, 'aborted')
+3 -2
View File
@@ -17,7 +17,8 @@ export interface StorageRepo {
count(): Promise<number>
update(id: string, input: UpdateStorageInput): Promise<StorageRecord | null>
delete(id: string): Promise<DeleteStorageResult>
// Picks the oldest active storage with available capacity (uploads land here).
// Picks the oldest active storage with available capacity (uploads land here),
// or validates and returns the requested storage against the same eligibility.
// Throws 'No available storage' when none qualifies.
select(): Promise<StorageRecord>
select(id?: string): Promise<StorageRecord>
}
+1
View File
@@ -157,6 +157,7 @@ export const createMatterSchema = z.object({
parent: z.string().default(''),
dirtype: z.number().int().default(0),
onConflict: conflictStrategySchema.optional(),
storageId: z.string().min(1).optional(),
})
export type CreateMatterInput = z.infer<typeof createMatterSchema>
+8
View File
@@ -76,6 +76,14 @@ describe('createMatterSchema', () => {
}
})
it('accepts an optional target storage id', () => {
const result = createMatterSchema.safeParse({ name: 'file.txt', type: 'text/plain', storageId: 'st-1' })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.storageId).toBe('st-1')
}
})
it('rejects empty name', () => {
const result = createMatterSchema.safeParse({ name: '', type: 'text/plain' })
expect(result.success).toBe(false)
+31
View File
@@ -21,10 +21,21 @@ const ADMIN_STORAGES_KEYS = [
'admin.storages.colTitle',
'admin.storages.colBucket',
'admin.storages.colEndpoint',
'admin.storages.colEgressBilling',
'admin.storages.colStatus',
'admin.storages.colHealth',
'admin.storages.colActions',
'admin.storages.statusActive',
'admin.storages.statusInactive',
'admin.storages.healthUntested',
'admin.storages.healthTesting',
'admin.storages.testAction',
'admin.storages.testSuccess',
'admin.storages.testNoUploadUrl',
'admin.storages.testUploadFailed',
'admin.storages.testCleanupFailed',
'admin.storages.testCorsFailure',
'admin.storages.testCorsCaveat',
'admin.storages.fieldTitle',
'admin.storages.fieldBucket',
'admin.storages.fieldEndpoint',
@@ -32,10 +43,19 @@ const ADMIN_STORAGES_KEYS = [
'admin.storages.fieldAccessKey',
'admin.storages.fieldSecretKey',
'admin.storages.fieldCustomHost',
'admin.storages.fieldForcePathStyle',
'admin.storages.forcePathStyleHint',
'admin.storages.customHostPlaceholder',
'admin.storages.fieldCapacity',
'admin.storages.capacityUnlimited',
'admin.storages.capacityHint',
'admin.storages.egressBilling',
'admin.storages.egressBillingHint',
'admin.storages.egressBillingBusinessOnly',
'admin.storages.egressBillingUnit',
'admin.storages.egressBillingCredits',
'admin.storages.egressBillingRate',
'admin.storages.egressBillingOff',
]
const ADMIN_NAV_KEYS = ['admin.nav.management', 'admin.nav.storages', 'admin.nav.users']
@@ -47,6 +67,9 @@ const ALL_KEYS = [...ADMIN_STORAGES_KEYS, ...ADMIN_NAV_KEYS, ...SHARED_KEYS]
// Keys that contain interpolation placeholders and the expected placeholder tokens
const INTERPOLATED_KEYS: Record<string, string[]> = {
'admin.storages.deleteConfirm': ['{{title}}'],
'admin.storages.testUploadFailed': ['{{detail}}'],
'admin.storages.testCleanupFailed': ['{{detail}}'],
'admin.storages.egressBillingRate': ['{{credits}}', '{{unit}}'],
}
describe('admin.storages locale keys — presence', () => {
@@ -132,6 +155,14 @@ describe('admin.storages locale keys — English values contract', () => {
expect(enLocale['admin.storages.statusInactive']).toBe('Inactive')
})
it('admin.storages.colHealth is "Connection health"', () => {
expect(enLocale['admin.storages.colHealth']).toBe('Connection health')
})
it('admin.storages.testAction is "Test connection"', () => {
expect(enLocale['admin.storages.testAction']).toBe('Test connection')
})
it('admin.storages.deleteHasFiles is "Cannot delete storage that contains files."', () => {
expect(enLocale['admin.storages.deleteHasFiles']).toBe('Cannot delete storage that contains files.')
})
+10
View File
@@ -725,9 +725,19 @@
"admin.storages.colEndpoint": "Endpoint",
"admin.storages.colEgressBilling": "Egress billing",
"admin.storages.colStatus": "Status",
"admin.storages.colHealth": "Connection health",
"admin.storages.colActions": "Actions",
"admin.storages.statusActive": "Active",
"admin.storages.statusInactive": "Inactive",
"admin.storages.healthUntested": "Not tested",
"admin.storages.healthTesting": "Testing...",
"admin.storages.testAction": "Test connection",
"admin.storages.testSuccess": "Upload test succeeded",
"admin.storages.testNoUploadUrl": "The server did not return an upload URL.",
"admin.storages.testUploadFailed": "Upload failed: {{detail}}",
"admin.storages.testCleanupFailed": "Cleanup failed: {{detail}}",
"admin.storages.testCorsFailure": "The browser could not reach the presigned upload URL. This is usually a bucket CORS or endpoint reachability issue.",
"admin.storages.testCorsCaveat": "Apply CORS like this for the current admin origin, then also verify endpoint, region, bucket name, and credentials.",
"admin.storages.fieldTitle": "Title",
"admin.storages.fieldBucket": "Bucket",
"admin.storages.fieldEndpoint": "Endpoint",
+10
View File
@@ -725,9 +725,19 @@
"admin.storages.colEndpoint": "端点",
"admin.storages.colEgressBilling": "流量计费",
"admin.storages.colStatus": "状态",
"admin.storages.colHealth": "连接健康",
"admin.storages.colActions": "操作",
"admin.storages.statusActive": "正常",
"admin.storages.statusInactive": "未启用",
"admin.storages.healthUntested": "未测试",
"admin.storages.healthTesting": "测试中...",
"admin.storages.testAction": "测试连接",
"admin.storages.testSuccess": "上传测试成功",
"admin.storages.testNoUploadUrl": "服务端未返回上传 URL。",
"admin.storages.testUploadFailed": "上传失败:{{detail}}",
"admin.storages.testCleanupFailed": "清理失败:{{detail}}",
"admin.storages.testCorsFailure": "浏览器无法访问预签名上传 URL,通常是存储桶 CORS 或端点可达性问题。",
"admin.storages.testCorsCaveat": "为当前管理后台来源应用如下 CORS 配置,并同时检查端点、区域、存储桶名称和凭证。",
"admin.storages.fieldTitle": "标题",
"admin.storages.fieldBucket": "存储桶",
"admin.storages.fieldEndpoint": "端点",
+53 -1
View File
@@ -506,6 +506,22 @@ describe('api', () => {
expect(headers.get('Content-Type')).toContain('application/json')
})
it('includes storageId when creating a targeted object draft', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'new1', name: 'doc.pdf' }))
await createObject({
name: 'doc.pdf',
type: 'application/pdf',
size: 1024,
parent: 'root',
dirtype: 0,
storageId: 'st-1',
})
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(JSON.parse(init.body as string)).toMatchObject({ storageId: 'st-1' })
})
it('returns a folder without upload instructions', async () => {
const created = { id: 'folder1', name: 'photos', type: 'folder' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(created))
@@ -521,6 +537,32 @@ describe('api', () => {
await expect(createObject({ name: 'f', type: 't', parent: 'p', dirtype: 0 })).rejects.toThrow('quota exceeded')
})
it('throws ApiError with structured targeted-storage failures', async () => {
vi.mocked(fetch).mockResolvedValueOnce(
makeResponse(
{
error: {
code: 503,
message: 'Storage is not active or has no available capacity',
status: 'UNAVAILABLE',
details: [{ reason: 'NO_STORAGE_CONFIGURED', domain: 'zpan.dev' }],
},
},
false,
503,
),
)
await expect(
createObject({ name: 'f', type: 't', parent: 'p', dirtype: 0, storageId: 'st-full' }),
).rejects.toMatchObject({
name: 'ApiError',
status: 503,
message: 'Storage is not active or has no available capacity',
reason: 'NO_STORAGE_CONFIGURED',
})
})
})
describe('completeObjectUpload', () => {
@@ -555,7 +597,17 @@ describe('api', () => {
await expect(abortObjectUpload('obj-1', 'sess-1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/objects/obj-1/uploads/sess-1')
expect(url).toBe('/api/objects/obj-1/uploads/sess-1?')
expect(init.method).toBe('DELETE')
})
it('passes strict cleanup query when requested', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await abortObjectUpload('obj-1', 'sess-1', { strictStorageCleanup: true })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/objects/obj-1/uploads/sess-1?strictStorageCleanup=1')
expect(init.method).toBe('DELETE')
})
+8 -2
View File
@@ -219,6 +219,7 @@ export function createObject(data: {
parent: string
dirtype: number
onConflict?: ConflictStrategy
storageId?: string
}) {
return unwrap<CreateObjectResult>(objects.index.$post({ json: data }))
}
@@ -236,8 +237,13 @@ export function completeObjectUpload(id: string, uploadSessionId: string, parts:
}
// Abort an in-progress upload and discard the draft.
export function abortObjectUpload(id: string, uploadSessionId: string) {
return discard(objects[':id'].uploads[':uploadSessionId'].$delete({ param: { id, uploadSessionId } }))
export function abortObjectUpload(id: string, uploadSessionId: string, opts: { strictStorageCleanup?: boolean } = {}) {
return discard(
objects[':id'].uploads[':uploadSessionId'].$delete({
param: { id, uploadSessionId },
query: opts.strictStorageCleanup ? { strictStorageCleanup: '1' } : {},
}),
)
}
// Re-presign expired part URLs mid-upload (multipart only); the happy path uses
@@ -0,0 +1,170 @@
import { ObjectStatus, StorageStatus } from '@shared/constants'
import type { Storage } from '@shared/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { abortObjectUpload, type CreateObjectResult, createObject, listStorages } from '@/lib/api'
import { corsJsonForOrigin, StoragesPage } from './index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) => {
if (!values) return key
return Object.entries(values).reduce((message, [name, value]) => message.replace(`{{${name}}}`, value), key)
},
}),
}))
vi.mock('@/components/UpgradeHint', () => ({
UpgradeHint: () => <div>upgrade-hint</div>,
}))
vi.mock('@/components/admin/delete-storage-dialog', () => ({
DeleteStorageDialog: () => <div>delete-storage-dialog</div>,
}))
vi.mock('@/components/admin/storage-form-drawer', () => ({
StorageFormDrawer: () => <div>storage-form-drawer</div>,
}))
vi.mock('@/hooks/useEntitlement', () => ({
useEntitlement: () => ({
hasFeature: () => true,
}),
}))
vi.mock('@/lib/api', () => ({
abortObjectUpload: vi.fn(),
createObject: vi.fn(),
listStorages: vi.fn(),
}))
const storage: Storage = {
id: 'storage-1',
title: 'Primary storage',
bucket: 'bucket',
endpoint: 'https://s3.example.com',
region: 'auto',
accessKey: 'access-key',
secretKey: 'secret-key',
filePath: '',
customHost: null,
capacity: 0,
forcePathStyle: true,
egressCreditBillingEnabled: false,
egressCreditUnitBytes: 1073741824,
egressCreditPerUnit: 1,
used: 0,
status: StorageStatus.ACTIVE,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
const uploadDraft: CreateObjectResult = {
id: 'object-1',
orgId: 'org-1',
alias: 'alias-1',
name: '.zpan-storage-test.txt',
type: 'text/plain',
size: 29,
dirtype: 0,
parent: '',
object: 'tests/object-1',
storageId: 'storage-1',
status: ObjectStatus.DRAFT,
trashedAt: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
upload: { sessionId: 'session-1', urls: ['https://uploads.example.com/object-1'], partSize: 5_242_880 },
}
function renderStoragesPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<StoragesPage />
</QueryClientProvider>,
)
}
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
describe('admin storages CORS guidance', () => {
it('renders the bucket CORS policy required for browser-based storage tests', () => {
expect(JSON.parse(corsJsonForOrigin('https://preview.example.com'))).toEqual([
{
AllowedOrigins: ['https://preview.example.com'],
AllowedMethods: ['GET', 'PUT', 'POST', 'HEAD'],
AllowedHeaders: ['*'],
ExposeHeaders: ['ETag'],
MaxAgeSeconds: 3600,
},
])
})
})
describe('StoragesPage connection test action', () => {
it('creates a storage-targeted object, PUTs to S3, renders success, and cleans up strictly', async () => {
vi.mocked(listStorages).mockResolvedValue({ items: [storage], total: 1 })
vi.mocked(createObject).mockResolvedValue(uploadDraft)
vi.mocked(abortObjectUpload).mockResolvedValue(undefined)
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const view = renderStoragesPage()
fireEvent.click(await view.findByTitle('admin.storages.testAction'))
await waitFor(() =>
expect(createObject).toHaveBeenCalledWith(
expect.objectContaining({
storageId: 'storage-1',
name: expect.stringMatching(/^\.zpan-storage-test-\d+\.txt$/),
type: 'text/plain',
parent: '',
dirtype: 0,
}),
),
)
expect(fetchMock).toHaveBeenCalledWith(
'https://uploads.example.com/object-1',
expect.objectContaining({
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: expect.any(Blob),
}),
)
await view.findByText('admin.storages.testSuccess')
expect(abortObjectUpload).toHaveBeenCalledWith('object-1', 'session-1', { strictStorageCleanup: true })
})
it('renders current-origin CORS guidance when the browser cannot reach the presigned URL', async () => {
vi.mocked(listStorages).mockResolvedValue({ items: [storage], total: 1 })
vi.mocked(createObject).mockResolvedValue(uploadDraft)
vi.mocked(abortObjectUpload).mockResolvedValue(undefined)
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
const view = renderStoragesPage()
fireEvent.click(await view.findByTitle('admin.storages.testAction'))
await view.findByText('admin.storages.testCorsFailure')
expect(view.container.textContent).toContain('admin.storages.testCorsCaveat')
expect(view.container.textContent).toContain(window.location.origin)
expect(view.container.textContent).toContain('"AllowedMethods": [')
expect(view.container.textContent).toContain('"GET"')
expect(view.container.textContent).toContain('"PUT"')
expect(view.container.textContent).toContain('"POST"')
expect(view.container.textContent).toContain('"HEAD"')
expect(view.container.textContent).toContain('"MaxAgeSeconds": 3600')
expect(abortObjectUpload).toHaveBeenCalledWith('object-1', 'session-1', { strictStorageCleanup: true })
})
})
@@ -2,7 +2,7 @@ import { FREE_STORAGE_LIMIT, StorageStatus } from '@shared/constants'
import type { Storage } from '@shared/types'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Database, Pencil, Plus, Trash2 } from 'lucide-react'
import { AlertTriangle, CheckCircle2, Database, Loader2, Pencil, Plus, TestTube2, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DeleteStorageDialog } from '@/components/admin/delete-storage-dialog'
@@ -10,19 +10,51 @@ import { StorageFormDrawer } from '@/components/admin/storage-form-drawer'
import { UpgradeHint } from '@/components/UpgradeHint'
import { Button } from '@/components/ui/button'
import { useEntitlement } from '@/hooks/useEntitlement'
import { listStorages } from '@/lib/api'
import { ApiError, abortObjectUpload, createObject, listStorages } from '@/lib/api'
import { formatSize } from '@/lib/format'
export const Route = createFileRoute('/_authenticated/admin/storages/')({
component: StoragesPage,
})
function StoragesPage() {
type StorageHealth =
| { status: 'idle' }
| { status: 'testing' }
| { status: 'success'; message: string }
| { status: 'error'; message: string }
| { status: 'cors'; message: string; corsJson: string }
const TEST_CONTENT = 'zpan storage connection test\n'
export function corsJsonForOrigin(origin: string) {
return JSON.stringify(
[
{
AllowedOrigins: [origin],
AllowedMethods: ['GET', 'PUT', 'POST', 'HEAD'],
AllowedHeaders: ['*'],
ExposeHeaders: ['ETag'],
MaxAgeSeconds: 3600,
},
],
null,
2,
)
}
function readableError(error: unknown) {
if (error instanceof ApiError) return error.message
if (error instanceof Error) return error.message
return String(error)
}
export function StoragesPage() {
const { t } = useTranslation()
const { hasFeature } = useEntitlement()
const [formOpen, setFormOpen] = useState(false)
const [editingStorage, setEditingStorage] = useState<Storage | null>(null)
const [deleteTarget, setDeleteTarget] = useState<{ id: string; title: string } | null>(null)
const [healthByStorage, setHealthByStorage] = useState<Record<string, StorageHealth>>({})
const storagesQuery = useQuery({
queryKey: ['admin', 'storages'],
@@ -49,6 +81,67 @@ function StoragesPage() {
if (!open) setEditingStorage(null)
}
async function handleTest(storage: Storage) {
setHealthByStorage((current) => ({ ...current, [storage.id]: { status: 'testing' } }))
let draft: { id: string; upload?: { sessionId: string; urls: string[] } } | null = null
let result: StorageHealth | null = null
try {
const blob = new Blob([TEST_CONTENT], { type: 'text/plain' })
draft = await createObject({
name: `.zpan-storage-test-${Date.now()}.txt`,
type: 'text/plain',
size: blob.size,
parent: '',
dirtype: 0,
storageId: storage.id,
})
const upload = draft.upload
if (!upload?.urls[0]) throw new Error(t('admin.storages.testNoUploadUrl'))
let uploadResponse: Response
try {
uploadResponse = await fetch(upload.urls[0], {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: blob,
})
} catch {
result = {
status: 'cors',
message: t('admin.storages.testCorsFailure'),
corsJson: corsJsonForOrigin(window.location.origin),
}
return
}
if (!uploadResponse.ok) {
const body = await uploadResponse.text().catch(() => '')
const detail = body.trim() || uploadResponse.statusText || `HTTP ${uploadResponse.status}`
throw new Error(t('admin.storages.testUploadFailed', { detail }))
}
result = { status: 'success', message: t('admin.storages.testSuccess') }
} catch (error) {
result = { status: 'error', message: readableError(error) }
} finally {
if (draft?.upload) {
try {
await abortObjectUpload(draft.id, draft.upload.sessionId, { strictStorageCleanup: true })
} catch (cleanupError) {
const cleanupMessage = t('admin.storages.testCleanupFailed', { detail: readableError(cleanupError) })
result =
result?.status === 'success'
? { status: 'error', message: cleanupMessage }
: result
? { ...result, message: `${result.message} ${cleanupMessage}` }
: { status: 'error', message: cleanupMessage }
}
}
setHealthByStorage((current) => ({ ...current, [storage.id]: result ?? { status: 'idle' } }))
}
}
if (storagesQuery.isLoading) {
return (
<div className="flex items-center justify-center py-20 text-muted-foreground">
@@ -82,6 +175,7 @@ function StoragesPage() {
{t('admin.storages.colEgressBilling')}
</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colStatus')}</th>
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colHealth')}</th>
<th className="px-4 py-3 text-right font-medium">{t('admin.storages.colActions')}</th>
</tr>
</thead>
@@ -91,13 +185,15 @@ function StoragesPage() {
key={storage.id}
storage={storage}
hasTrafficBilling={hasTrafficBilling}
health={healthByStorage[storage.id] ?? { status: 'idle' }}
onTest={() => handleTest(storage)}
onEdit={() => handleEdit(storage)}
onDelete={() => setDeleteTarget({ id: storage.id, title: storage.title })}
/>
))}
{storages.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground">
<td colSpan={7} className="px-4 py-12 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Database className="h-10 w-10" />
<p>{t('admin.storages.noStorages')}</p>
@@ -128,11 +224,15 @@ function StoragesPage() {
function StorageTableRow({
storage,
hasTrafficBilling,
health,
onTest,
onEdit,
onDelete,
}: {
storage: Storage
hasTrafficBilling: boolean
health: StorageHealth
onTest: () => void
onEdit: () => void
onDelete: () => void
}) {
@@ -160,8 +260,20 @@ function StorageTableRow({
{isActive ? t('admin.storages.statusActive') : t('admin.storages.statusInactive')}
</span>
</td>
<td className="min-w-64 px-4 py-3">
<StorageHealthView health={health} />
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon-xs"
onClick={onTest}
title={t('admin.storages.testAction')}
disabled={health.status === 'testing'}
>
{health.status === 'testing' ? <Loader2 className="animate-spin" /> : <TestTube2 />}
</Button>
<Button variant="ghost" size="icon-xs" onClick={onEdit} title={t('common.edit')}>
<Pencil />
</Button>
@@ -173,3 +285,46 @@ function StorageTableRow({
</tr>
)
}
function StorageHealthView({ health }: { health: StorageHealth }) {
const { t } = useTranslation()
if (health.status === 'idle') {
return <span className="text-xs text-muted-foreground">{t('admin.storages.healthUntested')}</span>
}
if (health.status === 'testing') {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
{t('admin.storages.healthTesting')}
</span>
)
}
if (health.status === 'success') {
return (
<span className="inline-flex items-center gap-1 text-xs text-green-700 dark:text-green-400">
<CheckCircle2 className="h-3 w-3" />
{health.message}
</span>
)
}
return (
<div className="space-y-2">
<div className="flex items-start gap-1 text-xs text-destructive">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>{health.message}</span>
</div>
{health.status === 'cors' && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('admin.storages.testCorsCaveat')}</p>
<pre className="max-w-sm overflow-x-auto rounded border bg-muted p-2 text-xs text-muted-foreground">
{health.corsJson}
</pre>
</div>
)}
</div>
)
}