mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
fix(objects): load creator profiles on demand
Keep object list responses limited to recorded creator identities and resolve full profiles through the creator subresource. Normalize list avatar sizing and avoid retrying terminal creator lookup errors.
This commit is contained in:
@@ -55,6 +55,78 @@ func (e ActorAttributionType) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for ActorIdentityType.
|
||||
const (
|
||||
ActorIdentityTypeAgent ActorIdentityType = "agent"
|
||||
ActorIdentityTypeAnonymous ActorIdentityType = "anonymous"
|
||||
ActorIdentityTypeApiKey ActorIdentityType = "api_key"
|
||||
ActorIdentityTypeDevice ActorIdentityType = "device"
|
||||
ActorIdentityTypeOauth ActorIdentityType = "oauth"
|
||||
ActorIdentityTypeSystem ActorIdentityType = "system"
|
||||
ActorIdentityTypeTaskUpload ActorIdentityType = "task-upload"
|
||||
ActorIdentityTypeUser ActorIdentityType = "user"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the ActorIdentityType enum.
|
||||
func (e ActorIdentityType) Valid() bool {
|
||||
switch e {
|
||||
case ActorIdentityTypeAgent:
|
||||
return true
|
||||
case ActorIdentityTypeAnonymous:
|
||||
return true
|
||||
case ActorIdentityTypeApiKey:
|
||||
return true
|
||||
case ActorIdentityTypeDevice:
|
||||
return true
|
||||
case ActorIdentityTypeOauth:
|
||||
return true
|
||||
case ActorIdentityTypeSystem:
|
||||
return true
|
||||
case ActorIdentityTypeTaskUpload:
|
||||
return true
|
||||
case ActorIdentityTypeUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for ActorProfileType.
|
||||
const (
|
||||
ActorProfileTypeAgent ActorProfileType = "agent"
|
||||
ActorProfileTypeAnonymous ActorProfileType = "anonymous"
|
||||
ActorProfileTypeApiKey ActorProfileType = "api_key"
|
||||
ActorProfileTypeDevice ActorProfileType = "device"
|
||||
ActorProfileTypeOauth ActorProfileType = "oauth"
|
||||
ActorProfileTypeSystem ActorProfileType = "system"
|
||||
ActorProfileTypeTaskUpload ActorProfileType = "task-upload"
|
||||
ActorProfileTypeUser ActorProfileType = "user"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the ActorProfileType enum.
|
||||
func (e ActorProfileType) Valid() bool {
|
||||
switch e {
|
||||
case ActorProfileTypeAgent:
|
||||
return true
|
||||
case ActorProfileTypeAnonymous:
|
||||
return true
|
||||
case ActorProfileTypeApiKey:
|
||||
return true
|
||||
case ActorProfileTypeDevice:
|
||||
return true
|
||||
case ActorProfileTypeOauth:
|
||||
return true
|
||||
case ActorProfileTypeSystem:
|
||||
return true
|
||||
case ActorProfileTypeTaskUpload:
|
||||
return true
|
||||
case ActorProfileTypeUser:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for AdminAnalyticsGrowthComparisonCoverageQuality.
|
||||
const (
|
||||
AdminAnalyticsGrowthComparisonCoverageQualityExact AdminAnalyticsGrowthComparisonCoverageQuality = "exact"
|
||||
@@ -4121,6 +4193,29 @@ type ActorAttribution struct {
|
||||
// ActorAttributionType defines model for ActorAttribution.Type.
|
||||
type ActorAttributionType string
|
||||
|
||||
// ActorIdentity defines model for ActorIdentity.
|
||||
type ActorIdentity struct {
|
||||
Issuer *string `json:"issuer"`
|
||||
Ref *string `json:"ref"`
|
||||
Type ActorIdentityType `json:"type"`
|
||||
}
|
||||
|
||||
// ActorIdentityType defines model for ActorIdentity.Type.
|
||||
type ActorIdentityType string
|
||||
|
||||
// ActorProfile defines model for ActorProfile.
|
||||
type ActorProfile struct {
|
||||
Image *string `json:"image"`
|
||||
Issuer *string `json:"issuer"`
|
||||
Name string `json:"name"`
|
||||
ProfileUrl *string `json:"profileUrl,omitempty"`
|
||||
Ref *string `json:"ref"`
|
||||
Type ActorProfileType `json:"type"`
|
||||
}
|
||||
|
||||
// ActorProfileType defines model for ActorProfile.Type.
|
||||
type ActorProfileType string
|
||||
|
||||
// AdminAnalyticsGrowth defines model for AdminAnalyticsGrowth.
|
||||
type AdminAnalyticsGrowth struct {
|
||||
ActiveUserTrend []struct {
|
||||
@@ -5815,14 +5910,14 @@ type ManualImageDomainSettingsProvider string
|
||||
|
||||
// Matter defines model for Matter.
|
||||
type Matter struct {
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorAttribution `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorIdentity `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
|
||||
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
|
||||
Parent string `json:"parent"`
|
||||
@@ -5856,10 +5951,10 @@ type NotificationPage struct {
|
||||
|
||||
// ObjectListItem defines model for ObjectListItem.
|
||||
type ObjectListItem struct {
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorAttribution `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorIdentity `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
|
||||
// HasChildren Whether this folder contains at least one child folder.
|
||||
HasChildren bool `json:"hasChildren"`
|
||||
@@ -11048,6 +11143,9 @@ type ClientInterface interface {
|
||||
|
||||
CopyObject(ctx context.Context, id string, body CopyObjectJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// GetObjectCreator request
|
||||
GetObjectCreator(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// TransferObjectWithBody request with any body
|
||||
TransferObjectWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -12319,6 +12417,18 @@ func (c *Client) CopyObject(ctx context.Context, id string, body CopyObjectJSONR
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) GetObjectCreator(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewGetObjectCreatorRequest(c.Server, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) TransferObjectWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewTransferObjectRequestWithBody(c.Server, id, contentType, body)
|
||||
if err != nil {
|
||||
@@ -16403,6 +16513,40 @@ func NewCopyObjectRequestWithBody(server string, id string, contentType string,
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewGetObjectCreatorRequest generates requests for GetObjectCreator
|
||||
func NewGetObjectCreatorRequest(server string, id string) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
|
||||
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/api/objects/%s/creator", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewTransferObjectRequest calls the generic TransferObject builder with application/json body
|
||||
func NewTransferObjectRequest(server string, id string, body TransferObjectJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
@@ -21441,6 +21585,9 @@ type ClientWithResponsesInterface interface {
|
||||
|
||||
CopyObjectWithResponse(ctx context.Context, id string, body CopyObjectJSONRequestBody, reqEditors ...RequestEditorFn) (*CopyObjectResponse, error)
|
||||
|
||||
// GetObjectCreatorWithResponse request
|
||||
GetObjectCreatorWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetObjectCreatorResponse, error)
|
||||
|
||||
// TransferObjectWithBodyWithResponse request with any body
|
||||
TransferObjectWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TransferObjectResponse, error)
|
||||
|
||||
@@ -23440,14 +23587,14 @@ type CreateObjectResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON201 *struct {
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorAttribution `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorIdentity `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
|
||||
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
|
||||
Parent string `json:"parent"`
|
||||
@@ -23572,15 +23719,15 @@ type GetObjectResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *struct {
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorAttribution `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
DownloadUrl *string `json:"downloadUrl,omitempty"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorIdentity `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
DownloadUrl *string `json:"downloadUrl,omitempty"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
|
||||
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
|
||||
Parent string `json:"parent"`
|
||||
@@ -23685,6 +23832,38 @@ func (r CopyObjectResponse) ContentType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type GetObjectCreatorResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *ActorProfile
|
||||
JSON400 *Error
|
||||
JSON404 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r GetObjectCreatorResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r GetObjectCreatorResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
|
||||
func (r GetObjectCreatorResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type TransferObjectResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -27862,6 +28041,15 @@ func (c *ClientWithResponses) CopyObjectWithResponse(ctx context.Context, id str
|
||||
return ParseCopyObjectResponse(rsp)
|
||||
}
|
||||
|
||||
// GetObjectCreatorWithResponse request returning *GetObjectCreatorResponse
|
||||
func (c *ClientWithResponses) GetObjectCreatorWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetObjectCreatorResponse, error) {
|
||||
rsp, err := c.GetObjectCreator(ctx, id, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseGetObjectCreatorResponse(rsp)
|
||||
}
|
||||
|
||||
// TransferObjectWithBodyWithResponse request with arbitrary body returning *TransferObjectResponse
|
||||
func (c *ClientWithResponses) TransferObjectWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TransferObjectResponse, error) {
|
||||
rsp, err := c.TransferObjectWithBody(ctx, id, contentType, body, reqEditors...)
|
||||
@@ -31091,14 +31279,14 @@ func ParseCreateObjectResponse(rsp *http.Response) (*CreateObjectResponse, error
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201:
|
||||
var dest struct {
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorAttribution `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorIdentity `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
|
||||
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
|
||||
Parent string `json:"parent"`
|
||||
@@ -31257,15 +31445,15 @@ func ParseGetObjectResponse(rsp *http.Response) (*GetObjectResponse, error) {
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest struct {
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorAttribution `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
DownloadUrl *string `json:"downloadUrl,omitempty"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
Alias string `json:"alias"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
CreatedBy *ActorIdentity `json:"createdBy"`
|
||||
Dirtype *int `json:"dirtype"`
|
||||
DownloadUrl *string `json:"downloadUrl,omitempty"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Object string `json:"object"`
|
||||
OrgId string `json:"orgId"`
|
||||
|
||||
// Parent Slash-delimited parent folder path relative to the workspace root; empty for root objects.
|
||||
Parent string `json:"parent"`
|
||||
@@ -31394,6 +31582,46 @@ func ParseCopyObjectResponse(rsp *http.Response) (*CopyObjectResponse, error) {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseGetObjectCreatorResponse parses an HTTP response from a GetObjectCreatorWithResponse call
|
||||
func ParseGetObjectCreatorResponse(rsp *http.Response) (*GetObjectCreatorResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &GetObjectCreatorResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest ActorProfile
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON404 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseTransferObjectResponse parses an HTTP response from a TransferObjectWithResponse call
|
||||
func ParseTransferObjectResponse(rsp *http.Response) (*TransferObjectResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
|
||||
+30
-13
@@ -117,7 +117,11 @@ test.describe('File table responsive columns', () => {
|
||||
test('desktop: creator uses an avatar-only column with identity details on hover @desktop', async ({ page }) => {
|
||||
await signUpAndGoToFiles(page)
|
||||
|
||||
const creatorResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().endsWith('/creator') && response.request().method() === 'GET',
|
||||
)
|
||||
await createFolder(page, 'test-folder')
|
||||
expect((await creatorResponsePromise).ok()).toBe(true)
|
||||
|
||||
await expect(page.getByRole('columnheader', { name: /size/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /modified/i })).toBeVisible()
|
||||
@@ -127,21 +131,20 @@ test.describe('File table responsive columns', () => {
|
||||
const creatorCell = row.getByRole('cell').nth(4)
|
||||
const creatorTrigger = creatorCell.getByRole('button', { name: /created by:/i })
|
||||
await expect(creatorTrigger).toBeVisible()
|
||||
const creatorLabel = await creatorTrigger.getAttribute('aria-label')
|
||||
const creatorName = creatorLabel?.replace(/^Created by:\s*/i, '')
|
||||
expect(creatorName).toBeTruthy()
|
||||
await expect(creatorTrigger).not.toContainText(creatorName!)
|
||||
|
||||
const resolvedCreatorLabel = await creatorTrigger.getAttribute('aria-label')
|
||||
const resolvedCreatorName = resolvedCreatorLabel?.replace(/^Created by:\s*/i, '')
|
||||
expect(resolvedCreatorName).toBeTruthy()
|
||||
await expect(creatorTrigger).not.toContainText(resolvedCreatorName!)
|
||||
await creatorTrigger.hover()
|
||||
const identityCard = page.locator('[data-slot="hover-card-content"]')
|
||||
await expect(identityCard).toBeVisible()
|
||||
await expect(identityCard.getByText(creatorName!, { exact: true })).toBeVisible()
|
||||
await expect(identityCard.getByText(resolvedCreatorName!, { exact: true })).toBeVisible()
|
||||
await expect(identityCard.getByText('User', { exact: true })).toBeVisible()
|
||||
|
||||
await page.getByLabel('Grid view').click()
|
||||
const gridCard = page.getByRole('button', { name: /test-folder/ })
|
||||
await expect(gridCard).toBeVisible()
|
||||
await expect(gridCard).not.toContainText(creatorName!)
|
||||
await expect(gridCard).not.toContainText(resolvedCreatorName!)
|
||||
|
||||
await page.getByLabel('List view').click()
|
||||
|
||||
@@ -149,7 +152,7 @@ test.describe('File table responsive columns', () => {
|
||||
await page.getByRole('menuitem', { name: /details/i }).click()
|
||||
const details = page.getByRole('dialog', { name: 'test-folder' })
|
||||
await expect(details.getByText(/created by/i)).toBeVisible()
|
||||
await expect(details.getByTitle(creatorName!)).toBeVisible()
|
||||
await expect(details.getByTitle(resolvedCreatorName!)).toBeVisible()
|
||||
})
|
||||
|
||||
test('desktop: creator hover card truncates long identity metadata @desktop', async ({ page }) => {
|
||||
@@ -169,18 +172,32 @@ test.describe('File table responsive columns', () => {
|
||||
type: 'agent',
|
||||
ref: 'agent-0123456789abcdef0123456789abcdef',
|
||||
issuer: actorIssuer,
|
||||
name: actorName,
|
||||
image: null,
|
||||
profileUrl: 'https://identity.example.com/agents/agent-0123456789abcdef0123456789abcdef',
|
||||
resolved: true,
|
||||
}
|
||||
}
|
||||
await route.fulfill({ response, json: body })
|
||||
})
|
||||
await page.route('**/api/objects/*/creator', async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
type: 'agent',
|
||||
ref: 'agent-0123456789abcdef0123456789abcdef',
|
||||
issuer: actorIssuer,
|
||||
name: actorName,
|
||||
image: null,
|
||||
profileUrl: 'https://identity.example.com/agents/agent-0123456789abcdef0123456789abcdef',
|
||||
},
|
||||
})
|
||||
})
|
||||
const creatorResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().endsWith('/creator') && response.request().method() === 'GET',
|
||||
)
|
||||
await page.reload()
|
||||
expect((await creatorResponsePromise).ok()).toBe(true)
|
||||
|
||||
const row = page.getByRole('row', { name: /long-actor-folder/ })
|
||||
await row.getByRole('button', { name: `Created by: ${actorName}` }).hover()
|
||||
const creator = row.locator('[data-slot="hover-card-trigger"]')
|
||||
await expect(creator).toHaveAttribute('aria-label', `Created by: ${actorName}`)
|
||||
await creator.hover()
|
||||
const card = page.locator('[data-slot="hover-card-content"]')
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card.getByRole('link')).toHaveAttribute(
|
||||
|
||||
@@ -13,12 +13,14 @@ import { currentTrafficPeriod } from '../domain/quota.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../test/setup.js'
|
||||
import { type ConfirmUploadOptions, confirmUpload as confirmUploadUsecase } from '../usecases/object.js'
|
||||
import type {
|
||||
ActorIdentity,
|
||||
CopyMatterOptions,
|
||||
CreateMatterInput,
|
||||
Matter,
|
||||
MatterListFilters,
|
||||
UpdateMatterInput,
|
||||
} from '../usecases/ports.js'
|
||||
import { actorIdentityKey } from '../usecases/ports.js'
|
||||
import { createCapacityRequestHash } from './objects.js'
|
||||
|
||||
type TestDbForMatter = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
@@ -150,14 +152,29 @@ async function insertFolder(
|
||||
async function insertFile(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
orgId: string,
|
||||
opts: { id: string; name: string; parent?: string; status?: string; size?: number; trashedAt?: number },
|
||||
opts: {
|
||||
id: string
|
||||
name: string
|
||||
parent?: string
|
||||
status?: string
|
||||
size?: number
|
||||
trashedAt?: number
|
||||
createdBy?: ActorIdentity
|
||||
},
|
||||
) {
|
||||
const now = Date.now()
|
||||
const status = opts.status ?? 'active'
|
||||
const size = opts.size ?? 100
|
||||
await db.run(sql`
|
||||
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
|
||||
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', ${size}, 0, ${opts.parent ?? ''}, 'some/key.txt', ${validStorage.id}, ${status}, ${opts.trashedAt ?? null}, ${now}, ${now})
|
||||
INSERT INTO matters (
|
||||
id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at,
|
||||
created_by_actor_type, created_by_actor_ref, created_by_actor_issuer, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', ${size}, 0,
|
||||
${opts.parent ?? ''}, 'some/key.txt', ${validStorage.id}, ${status}, ${opts.trashedAt ?? null},
|
||||
${opts.createdBy?.type ?? null}, ${opts.createdBy?.ref ?? null}, ${opts.createdBy?.issuer ?? null}, ${now}, ${now}
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -223,6 +240,83 @@ describe('Objects API', () => {
|
||||
expect(body).toEqual({ items: [], nextPageToken: null })
|
||||
})
|
||||
|
||||
it('GET /api/objects does not wait for external creator profiles [spec: objects/creator-attribution]', async () => {
|
||||
const { app, db, deps } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const identity = { type: 'oauth', ref: 'agent-1', issuer: 'https://id.realmroot.dev/api/auth' } as const
|
||||
await insertFile(db, orgId, { id: 'LazyCreatorFile', name: 'lazy.txt', createdBy: identity })
|
||||
vi.spyOn(deps.auditActorDirectory, 'listTrustedAgentIssuerOrigins').mockResolvedValue(
|
||||
new Set(['https://id.realmroot.dev']),
|
||||
)
|
||||
vi.spyOn(deps.agentInfo, 'resolve').mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
const res = await Promise.race([
|
||||
app.request('/api/objects?path=&pageSize=100', { headers }),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('object_list_waited_for_creator')), 100)),
|
||||
])
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: Array<{ id: string; createdBy: unknown }> }
|
||||
expect(body.items).toHaveLength(1)
|
||||
expect(body.items[0]).toMatchObject({ id: 'LazyCreatorFile' })
|
||||
expect(body.items[0]?.createdBy).toEqual(identity)
|
||||
expect(deps.agentInfo.resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('GET /api/objects/{id}/creator resolves the creator profile on demand', async () => {
|
||||
const { app, db, deps } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const identity = { type: 'oauth', ref: 'agent-1', issuer: 'https://id.realmroot.dev/api/auth' } as const
|
||||
await insertFile(db, orgId, { id: 'CreatorProfileFile', name: 'creator.txt', createdBy: identity })
|
||||
vi.spyOn(deps.auditActorDirectory, 'listTrustedAgentIssuerOrigins').mockResolvedValue(
|
||||
new Set(['https://id.realmroot.dev']),
|
||||
)
|
||||
vi.spyOn(deps.agentInfo, 'resolve').mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
actorIdentityKey(identity),
|
||||
{
|
||||
name: 'Jarvis',
|
||||
image: 'https://id.realmroot.dev/agent-picture-v1.svg',
|
||||
profileUrl: 'https://id.realmroot.dev/agents/agent-1',
|
||||
resolved: true,
|
||||
},
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
const res = await app.request('/api/objects/CreatorProfileFile/creator', { headers })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
...identity,
|
||||
name: 'Jarvis',
|
||||
image: 'https://id.realmroot.dev/agent-picture-v1.svg',
|
||||
profileUrl: 'https://id.realmroot.dev/agents/agent-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('GET /api/objects/{id}/creator does not return a fallback profile', async () => {
|
||||
const { app, db, deps } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
const identity = { type: 'oauth', ref: 'missing-agent', issuer: 'https://id.realmroot.dev/api/auth' } as const
|
||||
await insertFile(db, orgId, { id: 'MissingCreatorProfile', name: 'missing.txt', createdBy: identity })
|
||||
vi.spyOn(deps.auditActorDirectory, 'listTrustedAgentIssuerOrigins').mockResolvedValue(
|
||||
new Set(['https://id.realmroot.dev']),
|
||||
)
|
||||
vi.spyOn(deps.agentInfo, 'resolve').mockResolvedValue(new Map())
|
||||
|
||||
const res = await app.request('/api/objects/MissingCreatorProfile/creator', { headers })
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('GET /api/objects enforces the shared page-size limit', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
@@ -2909,11 +3003,13 @@ describe('Objects API — error branches', () => {
|
||||
})
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
const body = (await res.json()) as { createdBy: unknown; name: string; parent: string }
|
||||
expect(body).toMatchObject({
|
||||
name: '藏.mp3',
|
||||
parent: targetFolder,
|
||||
createdBy: { type: 'device', resolved: true },
|
||||
createdBy: { type: 'device' },
|
||||
})
|
||||
expect(body.createdBy).not.toHaveProperty('resolved')
|
||||
})
|
||||
|
||||
it('creates a folder with a workspace API key that has objects:create [spec: objects/creator-attribution]', async () => {
|
||||
@@ -2930,11 +3026,13 @@ describe('Objects API — error branches', () => {
|
||||
body: JSON.stringify({ name: 'api-key-folder', type: 'folder', dirtype: 1, parent: '' }),
|
||||
})
|
||||
expect(res.status).toBe(201)
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
const body = (await res.json()) as { createdBy: unknown; name: string; orgId: string }
|
||||
expect(body).toMatchObject({
|
||||
name: 'api-key-folder',
|
||||
orgId,
|
||||
createdBy: { type: 'api_key', name: 'API key · test-api-key', resolved: true },
|
||||
createdBy: { type: 'api_key' },
|
||||
})
|
||||
expect(body.createdBy).not.toHaveProperty('resolved')
|
||||
})
|
||||
|
||||
it('returns null instead of attributing a legacy object to the workspace owner [spec: objects/legacy-creator]', async () => {
|
||||
@@ -2948,6 +3046,9 @@ describe('Objects API — error branches', () => {
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toMatchObject({ id: 'legacy-creator-file', createdBy: null })
|
||||
|
||||
const creatorRes = await app.request('/api/objects/legacy-creator-file/creator', { headers })
|
||||
expect(creatorRes.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 403 when an object API key is missing the route scope', async () => {
|
||||
|
||||
+42
-15
@@ -1,7 +1,8 @@
|
||||
import { OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { AuthorizationScope } from '@shared/authorization'
|
||||
import {
|
||||
actorAttributionSchema,
|
||||
actorIdentitySchema,
|
||||
actorProfileSchema,
|
||||
completeObjectUploadSchema,
|
||||
copyObjectBodySchema,
|
||||
createMatterSchema,
|
||||
@@ -26,11 +27,12 @@ import {
|
||||
copyObject,
|
||||
createObject,
|
||||
getObject,
|
||||
getObjectCreator,
|
||||
listObjects,
|
||||
matterCreatorIdentity,
|
||||
type ObjectActor,
|
||||
ObjectUploadSessionError,
|
||||
presignUploadSessionParts,
|
||||
resolveMatterCreators,
|
||||
transferObject,
|
||||
trashObject,
|
||||
updateObject,
|
||||
@@ -62,7 +64,7 @@ const matterSchema = z
|
||||
storageId: opaqueIdSchema,
|
||||
status: z.string(),
|
||||
trashedAt: z.number().int().nullable(),
|
||||
createdBy: actorAttributionSchema.nullable(),
|
||||
createdBy: actorIdentitySchema.nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
@@ -109,9 +111,8 @@ function toObjectListItemDTO(item: MatterListItem, createdBy: MatterDTO['created
|
||||
return { ...toMatterDTO(item, createdBy), hasChildren: item.hasChildren }
|
||||
}
|
||||
|
||||
async function matterDTO(deps: Env['Variables']['deps'], matter: Matter): Promise<MatterDTO> {
|
||||
const creators = await resolveMatterCreators(deps, [matter])
|
||||
return toMatterDTO(matter, creators.get(matter.id) ?? null)
|
||||
function matterDTO(matter: Matter): MatterDTO {
|
||||
return toMatterDTO(matter, matterCreatorIdentity(matter))
|
||||
}
|
||||
|
||||
const objectPageSchema = cursorPageSchema(objectListItemSchema, 'ObjectPage')
|
||||
@@ -358,6 +359,23 @@ const getObjectRoute = authRoute(
|
||||
},
|
||||
)
|
||||
|
||||
const getObjectCreatorRoute = authRoute(
|
||||
{ scopes: [AuthorizationScope.OBJECTS_READ], minTeamRole: 'viewer' },
|
||||
{
|
||||
operationId: 'getObjectCreator',
|
||||
summary: 'Get object creator',
|
||||
tags: ['Objects'],
|
||||
method: 'get',
|
||||
path: '/{id}/creator',
|
||||
request: { params: idParam },
|
||||
responses: {
|
||||
200: jsonContent(actorProfileSchema, 'Object creator profile'),
|
||||
400: errorResponse('No active organization'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const patchObjectRoute = authRoute(
|
||||
{ scopes: [AuthorizationScope.OBJECTS_UPDATE], minTeamRole: 'editor' },
|
||||
{
|
||||
@@ -472,10 +490,9 @@ const objects = app
|
||||
},
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
const creators = await resolveMatterCreators(c.get('deps'), result.result.items)
|
||||
return c.json(
|
||||
{
|
||||
items: result.result.items.map((item) => toObjectListItemDTO(item, creators.get(item.id) ?? null)),
|
||||
items: result.result.items.map((item) => toObjectListItemDTO(item, matterCreatorIdentity(item))),
|
||||
nextPageToken: await encodeNextPageToken(c.get('platform'), result.result.nextBoundary, {
|
||||
query: fingerprint,
|
||||
codec: directoryCursorCodec,
|
||||
@@ -484,6 +501,16 @@ const objects = app
|
||||
200,
|
||||
)
|
||||
})
|
||||
.openapi(getObjectCreatorRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) throw badRequest('No active organization')
|
||||
const result = await getObjectCreator(c.get('deps'), {
|
||||
orgId,
|
||||
objectId: c.req.valid('param').id,
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(result.creator, 200)
|
||||
})
|
||||
.openapi(createObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) throw badRequest('No active organization')
|
||||
@@ -520,7 +547,7 @@ const objects = app
|
||||
402,
|
||||
)
|
||||
}
|
||||
const matter = await matterDTO(c.get('deps'), result.matter)
|
||||
const matter = matterDTO(result.matter)
|
||||
if ('upload' in result) return c.json({ ...matter, upload: result.upload }, 201)
|
||||
return c.json(matter, 201)
|
||||
})
|
||||
@@ -560,7 +587,7 @@ const objects = app
|
||||
if ('error' in result) throw result.error // quota exceeded
|
||||
throw new ObjectUploadSessionError('not_found') // draft gone
|
||||
}
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
|
||||
return c.json(matterDTO(result.matter), 200)
|
||||
})
|
||||
.openapi(abortUploadRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
@@ -603,9 +630,9 @@ const objects = app
|
||||
},
|
||||
result.receipt.trafficEventId,
|
||||
)
|
||||
return c.json({ ...(await matterDTO(c.get('deps'), result.matter)), downloadUrl: result.downloadUrl }, 200)
|
||||
return c.json({ ...matterDTO(result.matter), downloadUrl: result.downloadUrl }, 200)
|
||||
}
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
|
||||
return c.json(matterDTO(result.matter), 200)
|
||||
}
|
||||
throw result.error
|
||||
})
|
||||
@@ -618,7 +645,7 @@ const objects = app
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 200)
|
||||
return c.json(matterDTO(result.matter), 200)
|
||||
})
|
||||
// Soft delete: move a live object to trash. Permanent removal is
|
||||
// DELETE /trash/objects/{id}; discarding a draft is DELETE /{id}/uploads/{sid}.
|
||||
@@ -644,7 +671,7 @@ const objects = app
|
||||
input: { copyFrom: c.req.valid('param').id, parent: body.parent, onConflict: body.onConflict },
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(await matterDTO(c.get('deps'), result.matter), 201)
|
||||
return c.json(matterDTO(result.matter), 201)
|
||||
})
|
||||
.openapi(transferObjectRoute, async (c) => {
|
||||
const orgId = c.get('orgId')
|
||||
@@ -661,7 +688,7 @@ const objects = app
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(
|
||||
{
|
||||
saved: await Promise.all(result.result.saved.map((matter) => matterDTO(c.get('deps'), matter))),
|
||||
saved: result.result.saved.map(matterDTO),
|
||||
skipped: result.result.skipped,
|
||||
sourceDeleted: result.result.sourceDeleted,
|
||||
},
|
||||
|
||||
+33
-30
@@ -12,7 +12,7 @@
|
||||
|
||||
import { DirType } from '@shared/constants'
|
||||
import type {
|
||||
ActorAttribution,
|
||||
ActorProfile,
|
||||
CompleteObjectUploadInput,
|
||||
ConflictStrategy,
|
||||
CreateMatterInput,
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
} from '@shared/schemas'
|
||||
import type { ObjectUploadInstructions } from '@shared/types'
|
||||
import { buildObjectKey, fileExt } from '../lib/path-template'
|
||||
import { resolveActorAttributions } from './audit-actors'
|
||||
import { resolveActorProfiles } from './audit-actors'
|
||||
import type { Deps } from './deps'
|
||||
import { assertFolderNotUsedByDownload, ensureDownloadFolderPath } from './downloads/download-folders'
|
||||
import { assertTaskUploadAllowed } from './downloads/downloads'
|
||||
@@ -259,34 +259,37 @@ export async function createObject(
|
||||
return { ok: true, matter, upload }
|
||||
}
|
||||
|
||||
export async function resolveMatterCreators(
|
||||
deps: Pick<Deps, 'auditActorDirectory' | 'agentInfo'>,
|
||||
matters: readonly Matter[],
|
||||
): Promise<ReadonlyMap<string, ActorAttribution>> {
|
||||
const identities = matters.flatMap((matter) =>
|
||||
matter.createdByActorType && matter.createdByActorRef
|
||||
? [
|
||||
{
|
||||
type: matter.createdByActorType as ActorIdentity['type'],
|
||||
ref: matter.createdByActorRef,
|
||||
issuer: matter.createdByActorIssuer ?? null,
|
||||
} satisfies ActorIdentity,
|
||||
]
|
||||
: [],
|
||||
)
|
||||
const actors = await resolveActorAttributions(deps, identities)
|
||||
return new Map(
|
||||
matters.flatMap((matter) => {
|
||||
if (!matter.createdByActorType || !matter.createdByActorRef) return []
|
||||
const identity = {
|
||||
type: matter.createdByActorType as ActorIdentity['type'],
|
||||
ref: matter.createdByActorRef,
|
||||
issuer: matter.createdByActorIssuer ?? null,
|
||||
}
|
||||
const actor = actors.get(actorIdentityKey(identity))
|
||||
return actor ? [[matter.id, actor] as const] : []
|
||||
}),
|
||||
)
|
||||
export function matterCreatorIdentity(matter: Matter): ActorIdentity | null {
|
||||
if (!matter.createdByActorType || !matter.createdByActorRef) return null
|
||||
return {
|
||||
type: matter.createdByActorType as ActorIdentity['type'],
|
||||
ref: matter.createdByActorRef,
|
||||
issuer: matter.createdByActorIssuer ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export type GetObjectCreatorOutcome = { ok: true; creator: ActorProfile } | { ok: false; error: AppError }
|
||||
|
||||
export async function getObjectCreator(
|
||||
deps: Pick<Deps, 'matter' | 'auditActorDirectory' | 'agentInfo'>,
|
||||
params: { orgId: string; objectId: string },
|
||||
): Promise<GetObjectCreatorOutcome> {
|
||||
const matter = await deps.matter.get(params.objectId, params.orgId)
|
||||
if (!matter || matter.trashedAt != null) return { ok: false, error: notFound() }
|
||||
const identity = matterCreatorIdentity(matter)
|
||||
if (!identity) return { ok: false, error: notFound() }
|
||||
const profiles = await resolveActorProfiles(deps, [identity])
|
||||
const profile = profiles.get(actorIdentityKey(identity))
|
||||
if (!profile) return { ok: false, error: notFound() }
|
||||
return {
|
||||
ok: true,
|
||||
creator: {
|
||||
...identity,
|
||||
name: profile.name,
|
||||
image: profile.image,
|
||||
...(profile.profileUrl !== undefined ? { profileUrl: profile.profileUrl } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Decides the S3 mechanism, presigns every URL up front, and records the upload
|
||||
|
||||
@@ -11,6 +11,25 @@ export const actorTypeSchema = z.enum([
|
||||
'task-upload',
|
||||
])
|
||||
|
||||
export const actorIdentitySchema = z
|
||||
.object({
|
||||
type: actorTypeSchema,
|
||||
ref: z.string().nullable(),
|
||||
issuer: z.string().nullable(),
|
||||
})
|
||||
.openapi('ActorIdentity')
|
||||
|
||||
export const actorProfileSchema = z
|
||||
.object({
|
||||
type: actorTypeSchema,
|
||||
ref: z.string().nullable(),
|
||||
issuer: z.string().nullable(),
|
||||
name: z.string(),
|
||||
image: z.string().nullable(),
|
||||
profileUrl: z.string().url().nullable().optional(),
|
||||
})
|
||||
.openapi('ActorProfile')
|
||||
|
||||
export const actorAttributionSchema = z
|
||||
.object({
|
||||
type: actorTypeSchema,
|
||||
@@ -24,4 +43,6 @@ export const actorAttributionSchema = z
|
||||
.openapi('ActorAttribution')
|
||||
|
||||
export type ActorType = z.infer<typeof actorTypeSchema>
|
||||
export type ActorIdentity = z.infer<typeof actorIdentitySchema>
|
||||
export type ActorProfile = z.infer<typeof actorProfileSchema>
|
||||
export type ActorAttribution = z.infer<typeof actorAttributionSchema>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { opaqueIdSchema } from './id'
|
||||
|
||||
export type { ActorAttribution, ActorType } from './actors'
|
||||
export { actorAttributionSchema, actorTypeSchema } from './actors'
|
||||
export type { ActorAttribution, ActorIdentity, ActorProfile, ActorType } from './actors'
|
||||
export { actorAttributionSchema, actorIdentitySchema, actorProfileSchema, actorTypeSchema } from './actors'
|
||||
|
||||
export {
|
||||
adminAnalyticsGrowthSchema,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommercePayment, CommerceProduct, ProductPrice } from 'zpan-cloud-sdk'
|
||||
import type { DirType, ObjectStatus, StorageStatus, StorageStatusReason } from '../constants'
|
||||
import type { ActorAttribution } from '../schemas/actors'
|
||||
import type { ActorIdentity } from '../schemas/actors'
|
||||
import type {
|
||||
CloudOrder as ZPanCloudOrder,
|
||||
CloudOrderFulfillmentPayload as ZPanCloudOrderFulfillmentPayload,
|
||||
@@ -21,7 +21,7 @@ export interface StorageObject {
|
||||
status: ObjectStatus
|
||||
// Soft-delete marker: null = live, epoch ms = in trash.
|
||||
trashedAt: number | null
|
||||
createdBy?: ActorAttribution | null
|
||||
createdBy?: ActorIdentity | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ Feature: Objects
|
||||
Scenario: Object creation records the authenticated actor
|
||||
Given an authenticated user, API key, agent, or device
|
||||
When that actor creates an object
|
||||
Then the object stores the stable actor identity and returns its display profile
|
||||
Then the object stores and returns the stable actor identity
|
||||
And its display profile is available from the creator subresource
|
||||
|
||||
@objects/legacy-creator @api
|
||||
Scenario: Historical objects do not impersonate the workspace owner
|
||||
|
||||
@@ -7,7 +7,10 @@ vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => (key === 'actors.notRecorded' ? '-' : key) }),
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ActorIdentity', () => {
|
||||
it('renders the resolved name and avatar', () => {
|
||||
@@ -53,6 +56,39 @@ describe('ActorIdentity', () => {
|
||||
})
|
||||
|
||||
describe('ActorAvatarHoverCard', () => {
|
||||
it('uses the same fixed avatar box with an image or fallback', async () => {
|
||||
class LoadedImage {
|
||||
addEventListener(type: string, listener: EventListener) {
|
||||
if (type === 'load') queueMicrotask(() => listener(new Event('load')))
|
||||
}
|
||||
removeEventListener() {}
|
||||
}
|
||||
vi.stubGlobal('Image', LoadedImage)
|
||||
const actor: ActorAttribution = {
|
||||
type: 'agent',
|
||||
ref: 'agent-1',
|
||||
issuer: 'https://realm.example.com',
|
||||
name: 'Research Agent',
|
||||
image: 'https://example.com/agent.png',
|
||||
resolved: true,
|
||||
}
|
||||
const { rerender } = render(<ActorAvatarHoverCard actor={actor} />)
|
||||
|
||||
await waitFor(() => expect(document.querySelector('[data-slot="avatar-image"]')).toBeTruthy())
|
||||
const image = document.querySelector('[data-slot="avatar-image"]')
|
||||
const imageAvatar = document.querySelector('[data-slot="avatar"]')
|
||||
expect(imageAvatar?.getAttribute('data-size')).toBe('default')
|
||||
expect(image?.getAttribute('width')).toBe('32')
|
||||
expect(image?.getAttribute('height')).toBe('32')
|
||||
expect(image?.className).toContain('object-cover')
|
||||
|
||||
rerender(<ActorAvatarHoverCard actor={{ ...actor, image: null }} />)
|
||||
|
||||
const fallbackAvatar = document.querySelector('[data-slot="avatar"]')
|
||||
expect(fallbackAvatar?.getAttribute('data-size')).toBe('default')
|
||||
expect(fallbackAvatar?.className).toBe(imageAvatar?.className)
|
||||
})
|
||||
|
||||
it('keeps the file-list cell avatar-only and reveals the detailed identity card on hover', async () => {
|
||||
const actor: ActorAttribution = {
|
||||
type: 'oauth',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ActorAttribution } from '@shared/schemas'
|
||||
import type { ActorAttribution, ActorIdentity as ActorIdentityData, ActorProfile } from '@shared/schemas'
|
||||
import type { AuditEvent } from '@shared/types'
|
||||
import { Bot, CircleHelp, ExternalLink, KeyRound, Monitor, UserRound } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -9,11 +9,16 @@ import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ActorIdentityProps {
|
||||
actor?: ActorAttribution | null
|
||||
actor?: ActorAttribution | ActorProfile | ActorIdentityData | null
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ActorAvatarHoverCardProps extends Pick<ActorIdentityProps, 'actor' | 'className'> {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
size?: 'sm' | 'default' | 'lg'
|
||||
}
|
||||
|
||||
export function auditEventActor(event: AuditEvent): ActorAttribution {
|
||||
return {
|
||||
type: event.actorType,
|
||||
@@ -23,26 +28,39 @@ export function auditEventActor(event: AuditEvent): ActorAttribution {
|
||||
}
|
||||
}
|
||||
|
||||
function ActorFallback({ actor }: { actor: ActorAttribution }) {
|
||||
type DisplayActor = ActorAttribution | ActorProfile | ActorIdentityData
|
||||
const ACTOR_AVATAR_PIXELS = { sm: 24, default: 32, lg: 40 } as const
|
||||
|
||||
function hasActorProfile(actor: DisplayActor): actor is ActorAttribution | ActorProfile {
|
||||
return 'name' in actor
|
||||
}
|
||||
|
||||
function ActorFallback({ actor }: { actor: DisplayActor }) {
|
||||
if (actor.type === 'api_key') return <KeyRound aria-hidden="true" />
|
||||
if (actor.type === 'device') return <Monitor aria-hidden="true" />
|
||||
if (actor.type === 'oauth' || actor.type === 'agent') return <Bot aria-hidden="true" />
|
||||
if (actor.type === 'user') {
|
||||
const initials = actor.name
|
||||
.split(/\s+/)
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
return initials || <UserRound aria-hidden="true" />
|
||||
if (hasActorProfile(actor)) {
|
||||
const initials = actor.name
|
||||
.split(/\s+/)
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()
|
||||
if (initials) return initials
|
||||
}
|
||||
return <UserRound aria-hidden="true" />
|
||||
}
|
||||
return <CircleHelp aria-hidden="true" />
|
||||
}
|
||||
|
||||
function ActorAvatar({ actor, size = 'sm' }: { actor: ActorAttribution; size?: 'sm' | 'default' | 'lg' }) {
|
||||
function ActorAvatar({ actor, size = 'sm' }: { actor: DisplayActor; size?: 'sm' | 'default' | 'lg' }) {
|
||||
const pixels = ACTOR_AVATAR_PIXELS[size]
|
||||
return (
|
||||
<Avatar size={size}>
|
||||
{actor.image && <AvatarImage src={actor.image} alt="" />}
|
||||
{hasActorProfile(actor) && actor.image && (
|
||||
<AvatarImage src={actor.image} alt="" width={pixels} height={pixels} className="block size-full object-cover" />
|
||||
)}
|
||||
<AvatarFallback className="[&_svg]:size-3">
|
||||
<ActorFallback actor={actor} />
|
||||
</AvatarFallback>
|
||||
@@ -56,36 +74,36 @@ export function ActorIdentity({ actor, compact = false, className }: ActorIdenti
|
||||
return <span className={cn('text-muted-foreground', className)}>{t('actors.notRecorded')}</span>
|
||||
}
|
||||
|
||||
const name = hasActorProfile(actor) ? actor.name : t(`actors.type.${actor.type}`)
|
||||
return (
|
||||
<span className={cn('inline-flex min-w-0 items-center gap-2', className)} title={actor.name}>
|
||||
<span className={cn('inline-flex min-w-0 items-center gap-2', className)} title={name}>
|
||||
<ActorAvatar actor={actor} />
|
||||
<span className={cn('min-w-0 truncate', compact ? 'text-xs text-muted-foreground' : 'text-sm')}>
|
||||
{actor.name}
|
||||
</span>
|
||||
<span className={cn('min-w-0 truncate', compact ? 'text-xs text-muted-foreground' : 'text-sm')}>{name}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActorAvatarHoverCard({ actor, className }: Pick<ActorIdentityProps, 'actor' | 'className'>) {
|
||||
export function ActorAvatarHoverCard({ actor, className, onOpenChange, size = 'default' }: ActorAvatarHoverCardProps) {
|
||||
const { t } = useTranslation()
|
||||
if (!actor) {
|
||||
return <span className={cn('text-muted-foreground', className)}>{t('actors.notRecorded')}</span>
|
||||
}
|
||||
|
||||
const name = hasActorProfile(actor) ? actor.name : t(`actors.type.${actor.type}`)
|
||||
return (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCard openDelay={200} closeDelay={100} onOpenChange={onOpenChange}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex cursor-help rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'flex cursor-help rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
className,
|
||||
)}
|
||||
aria-label={`${t('files.createdBy')}: ${actor.name}`}
|
||||
aria-label={`${t('files.createdBy')}: ${name}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ActorAvatar actor={actor} size="default" />
|
||||
<ActorAvatar actor={actor} size={size} />
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-64 max-w-[calc(100vw-2rem)] overflow-hidden">
|
||||
@@ -114,15 +132,17 @@ export function ActorAvatarHoverCard({ actor, className }: Pick<ActorIdentityPro
|
||||
)
|
||||
}
|
||||
|
||||
function ActorProfileCardHeader({ actor }: { actor: ActorAttribution }) {
|
||||
function ActorProfileCardHeader({ actor }: { actor: DisplayActor }) {
|
||||
const { t } = useTranslation()
|
||||
const name = hasActorProfile(actor) ? actor.name : t(`actors.type.${actor.type}`)
|
||||
const profileUrl = hasActorProfile(actor) ? actor.profileUrl : undefined
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 overflow-hidden">
|
||||
<ActorAvatar actor={actor} size="lg" />
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-1 overflow-hidden">
|
||||
<span className="block w-full min-w-0 whitespace-normal break-words text-sm font-semibold" data-actor-field>
|
||||
{actor.name}
|
||||
{name}
|
||||
</span>
|
||||
<Badge variant="secondary" className="max-w-full min-w-0">
|
||||
<span className="min-w-0 truncate" data-actor-field>
|
||||
@@ -131,15 +151,15 @@ function ActorProfileCardHeader({ actor }: { actor: ActorAttribution }) {
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{actor.profileUrl && <ExternalLink aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />}
|
||||
{profileUrl && <ExternalLink aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />}
|
||||
</>
|
||||
)
|
||||
|
||||
if (!actor.profileUrl) return <div className="flex w-full min-w-0 items-center gap-3 overflow-hidden">{content}</div>
|
||||
if (!profileUrl) return <div className="flex w-full min-w-0 items-center gap-3 overflow-hidden">{content}</div>
|
||||
|
||||
return (
|
||||
<a
|
||||
href={actor.profileUrl}
|
||||
href={profileUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex w-full min-w-0 items-center gap-3 overflow-hidden rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { StorageObject } from '@shared/types'
|
||||
import type { ColumnDef, Row } from '@tanstack/react-table'
|
||||
import { ActorAvatarHoverCard } from '@/components/actor-identity'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { formatDate, formatSize } from '@/lib/format'
|
||||
import { FileIcon } from './file-icon'
|
||||
import { FileRowActions } from './file-row-actions'
|
||||
import { ObjectCreatorAvatar } from './object-creator'
|
||||
import type { FileActionHandlers } from './types'
|
||||
|
||||
function foldersFirstSort(rowA: Row<StorageObject>, rowB: Row<StorageObject>): number {
|
||||
@@ -95,7 +95,7 @@ export function getColumns(
|
||||
{
|
||||
id: 'createdBy',
|
||||
header: t('files.colCreatedBy'),
|
||||
cell: ({ row }) => <ActorAvatarHoverCard actor={row.original.createdBy} />,
|
||||
cell: ({ row }) => <ObjectCreatorAvatar item={row.original} />,
|
||||
size: 64,
|
||||
meta: { className: 'hidden text-center lg:table-cell' },
|
||||
enableSorting: false,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { StorageObject } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getObjectCreator } from '@/lib/api'
|
||||
import { FileDetailsSheet } from './file-details-sheet'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -9,6 +11,7 @@ vi.mock('react-i18next', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./file-icon', () => ({ FileIcon: () => <span data-testid="file-icon" /> }))
|
||||
vi.mock('@/lib/api', () => ({ getObjectCreator: vi.fn() }))
|
||||
|
||||
const item: StorageObject = {
|
||||
id: 'file-1',
|
||||
@@ -27,22 +30,32 @@ const item: StorageObject = {
|
||||
type: 'agent',
|
||||
ref: 'agent-1',
|
||||
issuer: 'https://realm.example.com',
|
||||
name: 'Report Agent',
|
||||
image: null,
|
||||
resolved: true,
|
||||
},
|
||||
createdAt: '2026-08-01T12:00:00.000Z',
|
||||
updatedAt: '2026-08-02T12:00:00.000Z',
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('FileDetailsSheet', () => {
|
||||
it('shows creator and file metadata', () => {
|
||||
render(<FileDetailsSheet item={item} onOpenChange={vi.fn()} />)
|
||||
it('loads the creator profile and shows file metadata', async () => {
|
||||
vi.mocked(getObjectCreator).mockResolvedValue({
|
||||
...item.createdBy!,
|
||||
name: 'Report Agent',
|
||||
image: null,
|
||||
})
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FileDetailsSheet item={item} onOpenChange={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('report.pdf')).toBeTruthy()
|
||||
expect(screen.getByText('Report Agent')).toBeTruthy()
|
||||
expect(await screen.findByText('Report Agent')).toBeTruthy()
|
||||
expect(screen.getByText('/Reports')).toBeTruthy()
|
||||
expect(screen.getByText('1.0 KB')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { StorageObject } from '@shared/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ActorIdentity } from '@/components/actor-identity'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||
import { formatDate, formatSize } from '@/lib/format'
|
||||
import { FileIcon } from './file-icon'
|
||||
import { ObjectCreatorIdentity } from './object-creator'
|
||||
|
||||
interface FileDetailsSheetProps {
|
||||
item: StorageObject | null
|
||||
@@ -33,7 +33,7 @@ export function FileDetailsSheet({ item, onOpenChange }: FileDetailsSheetProps)
|
||||
<dl className="grid grid-cols-[7rem_minmax(0,1fr)] gap-x-4 gap-y-4 px-4 text-sm">
|
||||
<dt className="text-muted-foreground">{t('files.createdBy')}</dt>
|
||||
<dd className="min-w-0">
|
||||
<ActorIdentity actor={item.createdBy} />
|
||||
<ObjectCreatorIdentity item={item} />
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t('files.createdAt')}</dt>
|
||||
<dd>{formatDate(item.createdAt)}</dd>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { StorageObject } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, getObjectCreator } from '@/lib/api'
|
||||
import { ObjectCreatorAvatar } from './object-creator'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/api')>()),
|
||||
getObjectCreator: vi.fn(),
|
||||
}))
|
||||
|
||||
const item: StorageObject = {
|
||||
id: 'file-1',
|
||||
orgId: 'org-1',
|
||||
alias: 'alias-1',
|
||||
name: 'report.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 1024,
|
||||
dirtype: DirType.FILE,
|
||||
parent: '',
|
||||
object: 'object-key',
|
||||
storageId: 'storage-1',
|
||||
status: 'active',
|
||||
trashedAt: null,
|
||||
createdBy: {
|
||||
type: 'agent',
|
||||
ref: 'agent-1',
|
||||
issuer: 'https://id.realmroot.dev/api/auth',
|
||||
},
|
||||
createdAt: '2026-08-01T12:00:00.000Z',
|
||||
updatedAt: '2026-08-02T12:00:00.000Z',
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ObjectCreatorAvatar', () => {
|
||||
it('loads the creator when the avatar is rendered', async () => {
|
||||
vi.mocked(getObjectCreator).mockResolvedValue({
|
||||
...item.createdBy!,
|
||||
name: 'Jarvis',
|
||||
image: 'https://id.realmroot.dev/agent-picture-v1.svg',
|
||||
profileUrl: 'https://id.realmroot.dev/agents/agent-1',
|
||||
})
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ObjectCreatorAvatar item={item} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(getObjectCreator).toHaveBeenCalledWith('file-1'))
|
||||
const trigger = await screen.findByLabelText('files.createdBy: Jarvis')
|
||||
expect(trigger.classList.contains('flex')).toBe(true)
|
||||
expect(trigger.classList.contains('inline-flex')).toBe(false)
|
||||
expect(trigger.querySelector('[data-slot="avatar"]')?.getAttribute('data-size')).toBe('sm')
|
||||
})
|
||||
|
||||
it('does not request a creator profile when attribution was not recorded', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ObjectCreatorAvatar item={{ ...item, createdBy: null }} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('actors.notRecorded')).toBeTruthy()
|
||||
expect(getObjectCreator).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not retry when the creator profile returns 404', async () => {
|
||||
vi.mocked(getObjectCreator).mockRejectedValue(
|
||||
new ApiError(404, {
|
||||
error: { code: 404, message: 'Creator not found', status: 'NOT_FOUND' },
|
||||
}),
|
||||
)
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retryDelay: 0 } } })
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ObjectCreatorAvatar item={item} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(queryClient.getQueryState(['objects', 'file-1', 'creator'])?.status).toBe('error'))
|
||||
expect(getObjectCreator).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ActorIdentity as ActorIdentityData, ActorProfile } from '@shared/schemas'
|
||||
import type { StorageObject } from '@shared/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ActorAvatarHoverCard, ActorIdentity } from '@/components/actor-identity'
|
||||
import { ApiError, getObjectCreator } from '@/lib/api'
|
||||
|
||||
function retryCreatorQuery(failureCount: number, error: Error): boolean {
|
||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500) return false
|
||||
return failureCount < 3
|
||||
}
|
||||
|
||||
function useObjectCreator(item: StorageObject, enabled: boolean): ActorProfile | ActorIdentityData | null | undefined {
|
||||
const query = useQuery({
|
||||
queryKey: ['objects', item.id, 'creator'],
|
||||
queryFn: () => getObjectCreator(item.id),
|
||||
enabled: enabled && Boolean(item.createdBy),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: retryCreatorQuery,
|
||||
})
|
||||
return query.data ?? item.createdBy
|
||||
}
|
||||
|
||||
export function ObjectCreatorAvatar({ item }: { item: StorageObject }) {
|
||||
const creator = useObjectCreator(item, true)
|
||||
return <ActorAvatarHoverCard actor={creator} size="sm" />
|
||||
}
|
||||
|
||||
export function ObjectCreatorIdentity({ item }: { item: StorageObject }) {
|
||||
const creator = useObjectCreator(item, true)
|
||||
return <ActorIdentity actor={creator} />
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
getLicensingStatus,
|
||||
getOAuthConsentContext,
|
||||
getObject,
|
||||
getObjectCreator,
|
||||
getProfile,
|
||||
getSession,
|
||||
getShare,
|
||||
@@ -276,6 +277,31 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getObjectCreator', () => {
|
||||
it('fetches the object creator subresource', async () => {
|
||||
const creator = {
|
||||
type: 'agent' as const,
|
||||
ref: 'agent-1',
|
||||
issuer: 'https://id.realmroot.dev/api/auth',
|
||||
name: 'Jarvis',
|
||||
image: null,
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(creator))
|
||||
|
||||
await expect(getObjectCreator('id1')).resolves.toEqual(creator)
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/objects/id1/creator')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('throws ApiError when the creator subresource fails', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
||||
|
||||
await expect(getObjectCreator('missing')).rejects.toThrow('not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('site invitations api', () => {
|
||||
it('lists site invitations with pagination', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ApiKeyMetadata, ApiKeyTemplate } from '@shared/api-key-templates'
|
||||
import type { OAuthProviderConfig } from '@shared/oauth-providers'
|
||||
import type {
|
||||
ActorProfile,
|
||||
AllowedImageMime,
|
||||
AnnouncementInput,
|
||||
CloudCreditBalanceResponse,
|
||||
@@ -309,6 +310,10 @@ export function getObject(id: string) {
|
||||
return unwrap<StorageObject & { downloadUrl?: string }>(objects[':id'].$get({ param: { id } }))
|
||||
}
|
||||
|
||||
export function getObjectCreator(id: string) {
|
||||
return unwrap<ActorProfile>(objects[':id'].creator.$get({ param: { id } }))
|
||||
}
|
||||
|
||||
// POST /api/objects returns a folder, or a file draft with `upload` instructions:
|
||||
// the server-decided part size and one presigned PUT URL per slice.
|
||||
export interface CreateObjectResult extends StorageObject {
|
||||
|
||||
Reference in New Issue
Block a user