mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 08:16:58 +08:00
[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:
committed by
GitHub
parent
f4b65e4987
commit
c7f3d11793
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user