mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 05:44:38 +08:00
* refactor(api)!: unify revoke/cancel on PUT /{resource}/{id}/status (#452)
Retire the misleading DELETE /shares/{token} and PATCH /store/orders/{orderId}
shapes in favor of the existing status-subresource convention already used by
background-jobs and download-tasks.
- Shares: PUT /api/shares/{token}/status {status:'revoked'} -> 200 + the updated
creator ShareView. revokeShare now resolves the share before the UPDATE (the
record is unresolvable once revoked) and builds the view via a composeShareView
helper shared with viewShare; concurrently-revoked tokens now return 404.
Removed the now-dead getCreatorByToken repo port/adapter method.
- Store: PUT /api/store/orders/{orderId}/status {status:'canceled'} -> 200. Only
the local route shape changed; the upstream cloud SDK $patch call is untouched.
- Frontend: deleteShare -> revokeShare and cancelCloudOrder now use .status.$put
via the Hono RPC client; updated the shares route component.
- Regenerated the Go OpenAPI client.
Note: revoking a share whose matter is trashed-but-not-purged now returns 404
(was 204), a consequence of reusing viewShare's resolution path.
Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(spec): rename share delete scenarios to revoke status-subresource
Align spec/shares.feature scenario tags (@shares/revoke,
@shares/revoke-non-creator) with the renamed [spec:] breadcrumbs so
lint:spec traceability passes.
Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(shares): keep revoke working for a trashed-but-not-purged matter
revokeShare switched to resolveByToken, which returned matter_trashed for a
soft-deleted (not purged) matter and was short-circuited to 404. Because
trashing a matter does not cascade to its shares, the share stayed active and
still appeared in the owner's list — so the owner could no longer revoke it
(privacy footgun: restoring the file re-exposed a share they believed revoked).
ShareResolution now carries the share/matter/recipient records on the
matter_trashed variant (and splits not_found/revoked into single-literal members
so control-flow narrowing works). Viewer-facing callers still branch on status,
so trashed -> 410 for viewers is unchanged. revokeShare treats matter_trashed as
revocable (ownership check, revokeByToken, revoked creator view), while not_found
and already-revoked still map to 404.
Adds unit coverage (trashed-matter revoke succeeds; non-creator still 403) and a
backend integration test (share a landing matter, trash it, PUT status revoked ->
200 + status:'revoked', DB flips).
Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+314
-243
@@ -1071,18 +1071,33 @@ func (e VerifySharePassword200JSONResponseBodyOk) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for RevokeShareJSONBodyStatus.
|
||||
const (
|
||||
RevokeShareJSONBodyStatusRevoked RevokeShareJSONBodyStatus = "revoked"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the RevokeShareJSONBodyStatus enum.
|
||||
func (e RevokeShareJSONBodyStatus) Valid() bool {
|
||||
switch e {
|
||||
case RevokeShareJSONBodyStatusRevoked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for ListAnnouncementsParamsScope.
|
||||
const (
|
||||
ListAnnouncementsParamsScopeActive ListAnnouncementsParamsScope = "active"
|
||||
ListAnnouncementsParamsScopeAll ListAnnouncementsParamsScope = "all"
|
||||
Active ListAnnouncementsParamsScope = "active"
|
||||
All ListAnnouncementsParamsScope = "all"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the ListAnnouncementsParamsScope enum.
|
||||
func (e ListAnnouncementsParamsScope) Valid() bool {
|
||||
switch e {
|
||||
case ListAnnouncementsParamsScopeActive:
|
||||
case Active:
|
||||
return true
|
||||
case ListAnnouncementsParamsScopeAll:
|
||||
case All:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -3479,6 +3494,14 @@ type VerifySharePasswordJSONBody struct {
|
||||
// VerifySharePassword200JSONResponseBodyOk defines parameters for VerifySharePassword.
|
||||
type VerifySharePassword200JSONResponseBodyOk bool
|
||||
|
||||
// RevokeShareJSONBody defines parameters for RevokeShare.
|
||||
type RevokeShareJSONBody struct {
|
||||
Status RevokeShareJSONBodyStatus `json:"status"`
|
||||
}
|
||||
|
||||
// RevokeShareJSONBodyStatus defines parameters for RevokeShare.
|
||||
type RevokeShareJSONBodyStatus string
|
||||
|
||||
// ListAnnouncementsParams defines parameters for ListAnnouncements.
|
||||
type ListAnnouncementsParams struct {
|
||||
Page *int `form:"page,omitempty" json:"page,omitempty"`
|
||||
@@ -4002,6 +4025,9 @@ type SaveShareJSONRequestBody SaveShareJSONBody
|
||||
// VerifySharePasswordJSONRequestBody defines body for VerifySharePassword for application/json ContentType.
|
||||
type VerifySharePasswordJSONRequestBody VerifySharePasswordJSONBody
|
||||
|
||||
// RevokeShareJSONRequestBody defines body for RevokeShare for application/json ContentType.
|
||||
type RevokeShareJSONRequestBody RevokeShareJSONBody
|
||||
|
||||
// CreateAnnouncementJSONRequestBody defines body for CreateAnnouncement for application/json ContentType.
|
||||
type CreateAnnouncementJSONRequestBody CreateAnnouncementJSONBody
|
||||
|
||||
@@ -5017,9 +5043,6 @@ type ClientInterface interface {
|
||||
|
||||
CreateShare(ctx context.Context, body CreateShareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// RevokeShare request
|
||||
RevokeShare(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// GetShare request
|
||||
GetShare(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -5036,6 +5059,11 @@ type ClientInterface interface {
|
||||
|
||||
VerifySharePassword(ctx context.Context, token string, body VerifySharePasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// RevokeShareWithBody request with any body
|
||||
RevokeShareWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
RevokeShare(ctx context.Context, token string, body RevokeShareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// ListAnnouncements request
|
||||
ListAnnouncements(ctx context.Context, params *ListAnnouncementsParams, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -5208,14 +5236,14 @@ type ClientInterface interface {
|
||||
// ListOrders request
|
||||
ListOrders(ctx context.Context, params *ListOrdersParams, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// ContinueOrderPayment request
|
||||
ContinueOrderPayment(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// CancelOrderWithBody request with any body
|
||||
CancelOrderWithBody(ctx context.Context, orderId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
CancelOrder(ctx context.Context, orderId string, body CancelOrderJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// ContinueOrderPayment request
|
||||
ContinueOrderPayment(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// ListStorePackages request
|
||||
ListStorePackages(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -7788,18 +7816,6 @@ func (c *Client) CreateShare(ctx context.Context, body CreateShareJSONRequestBod
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) RevokeShare(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewRevokeShareRequest(c.Server, token)
|
||||
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) GetShare(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewGetShareRequest(c.Server, token)
|
||||
if err != nil {
|
||||
@@ -7872,6 +7888,30 @@ func (c *Client) VerifySharePassword(ctx context.Context, token string, body Ver
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) RevokeShareWithBody(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewRevokeShareRequestWithBody(c.Server, token, contentType, body)
|
||||
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) RevokeShare(ctx context.Context, token string, body RevokeShareJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewRevokeShareRequest(c.Server, token, body)
|
||||
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) ListAnnouncements(ctx context.Context, params *ListAnnouncementsParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewListAnnouncementsRequest(c.Server, params)
|
||||
if err != nil {
|
||||
@@ -8616,6 +8656,18 @@ func (c *Client) ListOrders(ctx context.Context, params *ListOrdersParams, reqEd
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) ContinueOrderPayment(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewContinueOrderPaymentRequest(c.Server, orderId)
|
||||
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) CancelOrderWithBody(ctx context.Context, orderId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewCancelOrderRequestWithBody(c.Server, orderId, contentType, body)
|
||||
if err != nil {
|
||||
@@ -8640,18 +8692,6 @@ func (c *Client) CancelOrder(ctx context.Context, orderId string, body CancelOrd
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) ContinueOrderPayment(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewContinueOrderPaymentRequest(c.Server, orderId)
|
||||
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) ListStorePackages(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewListStorePackagesRequest(c.Server)
|
||||
if err != nil {
|
||||
@@ -14702,40 +14742,6 @@ func NewCreateShareRequestWithBody(server string, contentType string, body io.Re
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewRevokeShareRequest generates requests for RevokeShare
|
||||
func NewRevokeShareRequest(server string, token string) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
|
||||
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "token", token, 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/shares/%s", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewGetShareRequest generates requests for GetShare
|
||||
func NewGetShareRequest(server string, token string) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -14949,6 +14955,53 @@ func NewVerifySharePasswordRequestWithBody(server string, token string, contentT
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewRevokeShareRequest calls the generic RevokeShare builder with application/json body
|
||||
func NewRevokeShareRequest(server string, token string, body RevokeShareJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
return NewRevokeShareRequestWithBody(server, token, "application/json", bodyReader)
|
||||
}
|
||||
|
||||
// NewRevokeShareRequestWithBody generates requests for RevokeShare with any type of body
|
||||
func NewRevokeShareRequestWithBody(server string, token string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
|
||||
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "token", token, 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/shares/%s/status", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut, queryURL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewListAnnouncementsRequest generates requests for ListAnnouncements
|
||||
func NewListAnnouncementsRequest(server string, params *ListAnnouncementsParams) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -16840,53 +16893,6 @@ func NewListOrdersRequest(server string, params *ListOrdersParams) (*http.Reques
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewCancelOrderRequest calls the generic CancelOrder builder with application/json body
|
||||
func NewCancelOrderRequest(server string, orderId string, body CancelOrderJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
return NewCancelOrderRequestWithBody(server, orderId, "application/json", bodyReader)
|
||||
}
|
||||
|
||||
// NewCancelOrderRequestWithBody generates requests for CancelOrder with any type of body
|
||||
func NewCancelOrderRequestWithBody(server string, orderId string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
|
||||
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "orderId", orderId, 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/store/orders/%s", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPatch, queryURL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewContinueOrderPaymentRequest generates requests for ContinueOrderPayment
|
||||
func NewContinueOrderPaymentRequest(server string, orderId string) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -16921,6 +16927,53 @@ func NewContinueOrderPaymentRequest(server string, orderId string) (*http.Reques
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewCancelOrderRequest calls the generic CancelOrder builder with application/json body
|
||||
func NewCancelOrderRequest(server string, orderId string, body CancelOrderJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
return NewCancelOrderRequestWithBody(server, orderId, "application/json", bodyReader)
|
||||
}
|
||||
|
||||
// NewCancelOrderRequestWithBody generates requests for CancelOrder with any type of body
|
||||
func NewCancelOrderRequestWithBody(server string, orderId string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
var pathParam0 string
|
||||
|
||||
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "orderId", orderId, 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/store/orders/%s/status", pathParam0)
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut, queryURL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewListStorePackagesRequest generates requests for ListStorePackages
|
||||
func NewListStorePackagesRequest(server string) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -18460,9 +18513,6 @@ type ClientWithResponsesInterface interface {
|
||||
|
||||
CreateShareWithResponse(ctx context.Context, body CreateShareJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateShareResponse, error)
|
||||
|
||||
// RevokeShareWithResponse request
|
||||
RevokeShareWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error)
|
||||
|
||||
// GetShareWithResponse request
|
||||
GetShareWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*GetShareResponse, error)
|
||||
|
||||
@@ -18479,6 +18529,11 @@ type ClientWithResponsesInterface interface {
|
||||
|
||||
VerifySharePasswordWithResponse(ctx context.Context, token string, body VerifySharePasswordJSONRequestBody, reqEditors ...RequestEditorFn) (*VerifySharePasswordResponse, error)
|
||||
|
||||
// RevokeShareWithBodyWithResponse request with any body
|
||||
RevokeShareWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error)
|
||||
|
||||
RevokeShareWithResponse(ctx context.Context, token string, body RevokeShareJSONRequestBody, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error)
|
||||
|
||||
// ListAnnouncementsWithResponse request
|
||||
ListAnnouncementsWithResponse(ctx context.Context, params *ListAnnouncementsParams, reqEditors ...RequestEditorFn) (*ListAnnouncementsResponse, error)
|
||||
|
||||
@@ -18651,14 +18706,14 @@ type ClientWithResponsesInterface interface {
|
||||
// ListOrdersWithResponse request
|
||||
ListOrdersWithResponse(ctx context.Context, params *ListOrdersParams, reqEditors ...RequestEditorFn) (*ListOrdersResponse, error)
|
||||
|
||||
// ContinueOrderPaymentWithResponse request
|
||||
ContinueOrderPaymentWithResponse(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*ContinueOrderPaymentResponse, error)
|
||||
|
||||
// CancelOrderWithBodyWithResponse request with any body
|
||||
CancelOrderWithBodyWithResponse(ctx context.Context, orderId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CancelOrderResponse, error)
|
||||
|
||||
CancelOrderWithResponse(ctx context.Context, orderId string, body CancelOrderJSONRequestBody, reqEditors ...RequestEditorFn) (*CancelOrderResponse, error)
|
||||
|
||||
// ContinueOrderPaymentWithResponse request
|
||||
ContinueOrderPaymentWithResponse(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*ContinueOrderPaymentResponse, error)
|
||||
|
||||
// ListStorePackagesWithResponse request
|
||||
ListStorePackagesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListStorePackagesResponse, error)
|
||||
|
||||
@@ -24835,37 +24890,6 @@ func (r CreateShareResponse) ContentType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type RevokeShareResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON403 *Error
|
||||
JSON404 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r RevokeShareResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r RevokeShareResponse) 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 RevokeShareResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type GetShareResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -25002,6 +25026,38 @@ func (r VerifySharePasswordResponse) ContentType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type RevokeShareResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *ShareView
|
||||
JSON403 *Error
|
||||
JSON404 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r RevokeShareResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r RevokeShareResponse) 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 RevokeShareResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ListAnnouncementsResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -26507,40 +26563,6 @@ func (r ListOrdersResponse) ContentType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type CancelOrderResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *CloudStoreValue
|
||||
JSON400 *Error
|
||||
JSON403 *Error
|
||||
JSON404 *Error
|
||||
JSON502 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r CancelOrderResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r CancelOrderResponse) 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 CancelOrderResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ContinueOrderPaymentResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -26575,6 +26597,40 @@ func (r ContinueOrderPaymentResponse) ContentType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type CancelOrderResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *CloudStoreValue
|
||||
JSON400 *Error
|
||||
JSON403 *Error
|
||||
JSON404 *Error
|
||||
JSON502 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r CancelOrderResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r CancelOrderResponse) 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 CancelOrderResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ListStorePackagesResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -29158,15 +29214,6 @@ func (c *ClientWithResponses) CreateShareWithResponse(ctx context.Context, body
|
||||
return ParseCreateShareResponse(rsp)
|
||||
}
|
||||
|
||||
// RevokeShareWithResponse request returning *RevokeShareResponse
|
||||
func (c *ClientWithResponses) RevokeShareWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error) {
|
||||
rsp, err := c.RevokeShare(ctx, token, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseRevokeShareResponse(rsp)
|
||||
}
|
||||
|
||||
// GetShareWithResponse request returning *GetShareResponse
|
||||
func (c *ClientWithResponses) GetShareWithResponse(ctx context.Context, token string, reqEditors ...RequestEditorFn) (*GetShareResponse, error) {
|
||||
rsp, err := c.GetShare(ctx, token, reqEditors...)
|
||||
@@ -29219,6 +29266,23 @@ func (c *ClientWithResponses) VerifySharePasswordWithResponse(ctx context.Contex
|
||||
return ParseVerifySharePasswordResponse(rsp)
|
||||
}
|
||||
|
||||
// RevokeShareWithBodyWithResponse request with arbitrary body returning *RevokeShareResponse
|
||||
func (c *ClientWithResponses) RevokeShareWithBodyWithResponse(ctx context.Context, token string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error) {
|
||||
rsp, err := c.RevokeShareWithBody(ctx, token, contentType, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseRevokeShareResponse(rsp)
|
||||
}
|
||||
|
||||
func (c *ClientWithResponses) RevokeShareWithResponse(ctx context.Context, token string, body RevokeShareJSONRequestBody, reqEditors ...RequestEditorFn) (*RevokeShareResponse, error) {
|
||||
rsp, err := c.RevokeShare(ctx, token, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseRevokeShareResponse(rsp)
|
||||
}
|
||||
|
||||
// ListAnnouncementsWithResponse request returning *ListAnnouncementsResponse
|
||||
func (c *ClientWithResponses) ListAnnouncementsWithResponse(ctx context.Context, params *ListAnnouncementsParams, reqEditors ...RequestEditorFn) (*ListAnnouncementsResponse, error) {
|
||||
rsp, err := c.ListAnnouncements(ctx, params, reqEditors...)
|
||||
@@ -29763,6 +29827,15 @@ func (c *ClientWithResponses) ListOrdersWithResponse(ctx context.Context, params
|
||||
return ParseListOrdersResponse(rsp)
|
||||
}
|
||||
|
||||
// ContinueOrderPaymentWithResponse request returning *ContinueOrderPaymentResponse
|
||||
func (c *ClientWithResponses) ContinueOrderPaymentWithResponse(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*ContinueOrderPaymentResponse, error) {
|
||||
rsp, err := c.ContinueOrderPayment(ctx, orderId, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseContinueOrderPaymentResponse(rsp)
|
||||
}
|
||||
|
||||
// CancelOrderWithBodyWithResponse request with arbitrary body returning *CancelOrderResponse
|
||||
func (c *ClientWithResponses) CancelOrderWithBodyWithResponse(ctx context.Context, orderId string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CancelOrderResponse, error) {
|
||||
rsp, err := c.CancelOrderWithBody(ctx, orderId, contentType, body, reqEditors...)
|
||||
@@ -29780,15 +29853,6 @@ func (c *ClientWithResponses) CancelOrderWithResponse(ctx context.Context, order
|
||||
return ParseCancelOrderResponse(rsp)
|
||||
}
|
||||
|
||||
// ContinueOrderPaymentWithResponse request returning *ContinueOrderPaymentResponse
|
||||
func (c *ClientWithResponses) ContinueOrderPaymentWithResponse(ctx context.Context, orderId string, reqEditors ...RequestEditorFn) (*ContinueOrderPaymentResponse, error) {
|
||||
rsp, err := c.ContinueOrderPayment(ctx, orderId, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseContinueOrderPaymentResponse(rsp)
|
||||
}
|
||||
|
||||
// ListStorePackagesWithResponse request returning *ListStorePackagesResponse
|
||||
func (c *ClientWithResponses) ListStorePackagesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListStorePackagesResponse, error) {
|
||||
rsp, err := c.ListStorePackages(ctx, reqEditors...)
|
||||
@@ -39150,39 +39214,6 @@ func ParseCreateShareResponse(rsp *http.Response) (*CreateShareResponse, error)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseRevokeShareResponse parses an HTTP response from a RevokeShareWithResponse call
|
||||
func ParseRevokeShareResponse(rsp *http.Response) (*RevokeShareResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &RevokeShareResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON403 = &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
|
||||
}
|
||||
|
||||
// ParseGetShareResponse parses an HTTP response from a GetShareWithResponse call
|
||||
func ParseGetShareResponse(rsp *http.Response) (*GetShareResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
@@ -39387,6 +39418,46 @@ func ParseVerifySharePasswordResponse(rsp *http.Response) (*VerifySharePasswordR
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseRevokeShareResponse parses an HTTP response from a RevokeShareWithResponse call
|
||||
func ParseRevokeShareResponse(rsp *http.Response) (*RevokeShareResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &RevokeShareResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest ShareView
|
||||
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 == 403:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON403 = &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
|
||||
}
|
||||
|
||||
// ParseListAnnouncementsResponse parses an HTTP response from a ListAnnouncementsWithResponse call
|
||||
func ParseListAnnouncementsResponse(rsp *http.Response) (*ListAnnouncementsResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
@@ -41015,15 +41086,15 @@ func ParseListOrdersResponse(rsp *http.Response) (*ListOrdersResponse, error) {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseCancelOrderResponse parses an HTTP response from a CancelOrderWithResponse call
|
||||
func ParseCancelOrderResponse(rsp *http.Response) (*CancelOrderResponse, error) {
|
||||
// ParseContinueOrderPaymentResponse parses an HTTP response from a ContinueOrderPaymentWithResponse call
|
||||
func ParseContinueOrderPaymentResponse(rsp *http.Response) (*ContinueOrderPaymentResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &CancelOrderResponse{
|
||||
response := &ContinueOrderPaymentResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
@@ -41069,15 +41140,15 @@ func ParseCancelOrderResponse(rsp *http.Response) (*CancelOrderResponse, error)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseContinueOrderPaymentResponse parses an HTTP response from a ContinueOrderPaymentWithResponse call
|
||||
func ParseContinueOrderPaymentResponse(rsp *http.Response) (*ContinueOrderPaymentResponse, error) {
|
||||
// ParseCancelOrderResponse parses an HTTP response from a CancelOrderWithResponse call
|
||||
func ParseCancelOrderResponse(rsp *http.Response) (*CancelOrderResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &ContinueOrderPaymentResponse{
|
||||
response := &CancelOrderResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
@@ -88,10 +88,12 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
const row = rows[0]
|
||||
if (!row) return { status: 'not_found' }
|
||||
if (row.share.status === 'revoked') return { status: 'revoked' }
|
||||
if (row.matter.status === 'trashed') return { status: 'matter_trashed' }
|
||||
|
||||
const recipients = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, row.share.id))
|
||||
|
||||
if (row.matter.status === 'trashed')
|
||||
return { status: 'matter_trashed', share: row.share, matter: row.matter, recipients }
|
||||
|
||||
return { status: 'ok', share: row.share, matter: row.matter, recipients }
|
||||
},
|
||||
|
||||
@@ -170,11 +172,6 @@ export function createShareRepo(db: Database): ShareRepo {
|
||||
])
|
||||
},
|
||||
|
||||
async getCreatorByToken(token: string): Promise<string | null> {
|
||||
const rows = await db.select({ creatorId: shares.creatorId }).from(shares).where(eq(shares.token, token))
|
||||
return rows[0]?.creatorId ?? null
|
||||
},
|
||||
|
||||
async revokeByToken(token: string, creatorId: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.update(shares)
|
||||
|
||||
@@ -418,7 +418,7 @@ describe('GET /api/shares', () => {
|
||||
const body2 = (await res2.json()) as Record<string, unknown>
|
||||
|
||||
// Revoke the second share
|
||||
await app.request(`/api/shares/${body2.token}`, { method: 'DELETE', headers })
|
||||
await revokeRequest(app, body2.token as string, headers)
|
||||
|
||||
const resActive = await app.request('/api/shares?status=active', { headers })
|
||||
const activeBody = (await resActive.json()) as { items: unknown[]; total: number }
|
||||
@@ -859,22 +859,33 @@ describe('POST /api/shares/:token/objects', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DELETE /api/shares/:token ────────────────────────────────────────────────
|
||||
// ─── PUT /api/shares/:token/status ───────────────────────────────────────────
|
||||
|
||||
describe('DELETE /api/shares/:token (auth guard)', () => {
|
||||
const revokeRequest = (app: Awaited<ReturnType<typeof createTestApp>>['app'], token: string, headers: HeadersInit) =>
|
||||
app.request(`/api/shares/${token}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'revoked' }),
|
||||
})
|
||||
|
||||
describe('PUT /api/shares/:token/status (auth guard)', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const res = await app.request('/api/shares/some-token', { method: 'DELETE' })
|
||||
const res = await app.request('/api/shares/some-token/status', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'revoked' }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/shares/:token', () => {
|
||||
describe('PUT /api/shares/:token/status', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('creator can delete their share and share status becomes revoked in DB [spec: shares/delete]', async () => {
|
||||
it('creator can revoke their share, gets the revoked view, and DB status flips [spec: shares/revoke]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -884,14 +895,18 @@ describe('DELETE /api/shares/:token', () => {
|
||||
const createRes = await createShare(app, headers, { matterId: 'del1', kind: 'landing' })
|
||||
const token = ((await createRes.json()) as Record<string, unknown>).token as string
|
||||
|
||||
const res = await app.request(`/api/shares/${token}`, { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(204)
|
||||
const res = await revokeRequest(app, token, headers)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { token: string; status: string; id?: string }
|
||||
expect(body.token).toBe(token)
|
||||
expect(body.status).toBe('revoked')
|
||||
expect(body.id).toBeTruthy()
|
||||
|
||||
const rows = await db.select({ status: shares.status }).from(shares).where(eq(shares.token, token))
|
||||
expect(rows[0]?.status).toBe('revoked')
|
||||
})
|
||||
|
||||
it('returns 403 when non-creator tries to delete a share [spec: shares/delete-non-creator]', async () => {
|
||||
it('returns 403 when non-creator tries to revoke a share [spec: shares/revoke-non-creator]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertStorage(db)
|
||||
|
||||
@@ -903,7 +918,7 @@ describe('DELETE /api/shares/:token', () => {
|
||||
const token = ((await createRes.json()) as Record<string, unknown>).token as string
|
||||
|
||||
const headersB = await authedHeaders(app, `del-other-${nanoid()}@example.com`)
|
||||
const res = await app.request(`/api/shares/${token}`, { method: 'DELETE', headers: headersB })
|
||||
const res = await revokeRequest(app, token, headersB)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
@@ -911,9 +926,46 @@ describe('DELETE /api/shares/:token', () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
|
||||
const res = await app.request('/api/shares/does-not-exist', { method: 'DELETE', headers })
|
||||
const res = await revokeRequest(app, 'does-not-exist', headers)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 when revoking an already-revoked share', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertFile(db, orgId, { id: 'del3', name: 'twice.txt' })
|
||||
|
||||
const createRes = await createShare(app, headers, { matterId: 'del3', kind: 'landing' })
|
||||
const token = ((await createRes.json()) as Record<string, unknown>).token as string
|
||||
|
||||
expect((await revokeRequest(app, token, headers)).status).toBe(200)
|
||||
expect((await revokeRequest(app, token, headers)).status).toBe(404)
|
||||
})
|
||||
|
||||
it('creator can still revoke a share whose matter was trashed (not purged) [spec: shares/revoke-trashed-matter]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await insertFile(db, orgId, { id: 'del4', name: 'trashed-then-revoked.txt' })
|
||||
|
||||
const createRes = await createShare(app, headers, { matterId: 'del4', kind: 'landing' })
|
||||
const token = ((await createRes.json()) as Record<string, unknown>).token as string
|
||||
|
||||
// Soft-delete the matter without purging it — the share row stays active.
|
||||
await db.run(sql`UPDATE matters SET status = 'trashed' WHERE id = 'del4'`)
|
||||
|
||||
const res = await revokeRequest(app, token, headers)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { token: string; status: string }
|
||||
expect(body.token).toBe(token)
|
||||
expect(body.status).toBe('revoked')
|
||||
|
||||
const rows = await db.select({ status: shares.status }).from(shares).where(eq(shares.token, token))
|
||||
expect(rows[0]?.status).toBe('revoked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/shares?box=received', () => {
|
||||
@@ -980,8 +1032,8 @@ describe('GET /api/shares?box=received', () => {
|
||||
recipients: [{ recipientUserId: recipientId }],
|
||||
})
|
||||
const token = ((await created.json()) as Record<string, unknown>).token as string
|
||||
const revoke = await app.request(`/api/shares/${token}`, { method: 'DELETE', headers: creatorHeaders })
|
||||
expect(revoke.status).toBe(204)
|
||||
const revoke = await revokeRequest(app, token, creatorHeaders)
|
||||
expect(revoke.status).toBe(200)
|
||||
|
||||
const res = await app.request('/api/shares?box=received', { headers: recipientHeaders })
|
||||
const body = (await res.json()) as { total: number }
|
||||
|
||||
@@ -341,11 +341,14 @@ const revokeShareRoute = createRoute({
|
||||
operationId: 'revokeShare',
|
||||
summary: 'Revoke a share',
|
||||
tags: ['Shares'],
|
||||
method: 'delete',
|
||||
path: '/{token}',
|
||||
request: { params: z.object({ token: z.string() }) },
|
||||
method: 'put',
|
||||
path: '/{token}/status',
|
||||
request: {
|
||||
params: z.object({ token: z.string() }),
|
||||
...jsonBody(z.object({ status: z.literal('revoked') })),
|
||||
},
|
||||
responses: {
|
||||
204: { description: 'Revoked' },
|
||||
200: jsonContent(shareViewSchema, 'Revoked share'),
|
||||
403: errorResponse('Forbidden'),
|
||||
404: errorResponse('Not found'),
|
||||
},
|
||||
@@ -405,7 +408,7 @@ export const authedShares = authedApp
|
||||
userId: c.get('userId')!,
|
||||
orgId: c.get('orgId')!,
|
||||
})
|
||||
if (out.ok) return c.body(null, 204)
|
||||
if (out.ok) return c.json(toShareViewDTO(out.dto), 200)
|
||||
throw out.error
|
||||
})
|
||||
.openapi(saveShareRoute, async (c) => {
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('Share lifecycle audit events', () => {
|
||||
expect(meta.hasPassword).toBe(false)
|
||||
})
|
||||
|
||||
it('records share_revoke when a share is deleted', async () => {
|
||||
it('records share_revoke when a share is revoked', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -303,11 +303,12 @@ describe('Share lifecycle audit events', () => {
|
||||
})
|
||||
const { token } = (await createRes.json()) as { token: string }
|
||||
|
||||
const revokeRes = await app.request(`/api/shares/${token}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
const revokeRes = await app.request(`/api/shares/${token}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'revoked' }),
|
||||
})
|
||||
expect(revokeRes.status).toBe(204)
|
||||
expect(revokeRes.status).toBe(200)
|
||||
|
||||
const evt = await getLatestActivity(db, 'share_revoke')
|
||||
expect(evt).toBeDefined()
|
||||
|
||||
@@ -1026,8 +1026,8 @@ describe('Quota Store API', () => {
|
||||
status: 200,
|
||||
json: async () => cloudOrder({ id: 'order-cloud-1', target: { orgId, customerId: orgId } }),
|
||||
} as Response)
|
||||
const canceled = await app.request('/api/store/orders/order-cloud-1', {
|
||||
method: 'PATCH',
|
||||
const canceled = await app.request('/api/store/orders/order-cloud-1/status', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'canceled' }),
|
||||
})
|
||||
@@ -1072,8 +1072,8 @@ describe('Quota Store API', () => {
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
const canceled = await app.request('/api/store/orders/order-other-org', {
|
||||
method: 'PATCH',
|
||||
const canceled = await app.request('/api/store/orders/order-other-org/status', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'canceled' }),
|
||||
})
|
||||
@@ -2406,8 +2406,8 @@ describe('Quota Store API — storefront proxy error branches', () => {
|
||||
status: 500,
|
||||
json: async () => ({ error: 'cloud_boom' }),
|
||||
} as Response)
|
||||
const cancel = await app.request('/api/store/orders/order-err', {
|
||||
method: 'PATCH',
|
||||
const cancel = await app.request('/api/store/orders/order-err/status', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'canceled' }),
|
||||
})
|
||||
@@ -2423,8 +2423,8 @@ describe('Quota Store API — storefront proxy error branches', () => {
|
||||
|
||||
const orders = await app.request('/api/store/orders', { headers })
|
||||
const payment = await app.request('/api/store/orders/order-1/payments', { method: 'POST', headers })
|
||||
const cancel = await app.request('/api/store/orders/order-1', {
|
||||
method: 'PATCH',
|
||||
const cancel = await app.request('/api/store/orders/order-1/status', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'canceled' }),
|
||||
})
|
||||
|
||||
@@ -188,8 +188,8 @@ const cancelOrderRoute = createRoute({
|
||||
operationId: 'cancelOrder',
|
||||
summary: 'Cancel an order',
|
||||
tags: ['Store'],
|
||||
method: 'patch',
|
||||
path: '/orders/{orderId}',
|
||||
method: 'put',
|
||||
path: '/orders/{orderId}/status',
|
||||
middleware: [requireTeamRole('owner')] as const,
|
||||
request: { params: z.object({ orderId: z.string() }), ...jsonBody(z.object({ status: z.literal('canceled') })) },
|
||||
responses: {
|
||||
|
||||
@@ -49,7 +49,11 @@ export interface ShareListItem {
|
||||
|
||||
export type ShareResolution =
|
||||
| { status: 'ok'; share: ShareRecord; matter: Matter; recipients: ShareRecipientRecord[] }
|
||||
| { status: 'not_found' | 'revoked' | 'matter_trashed' }
|
||||
// matter_trashed carries the records so the creator-only revoke path can still
|
||||
// act on the share; viewer-facing callers branch on `status` and ignore them.
|
||||
| { status: 'matter_trashed'; share: ShareRecord; matter: Matter; recipients: ShareRecipientRecord[] }
|
||||
| { status: 'not_found' }
|
||||
| { status: 'revoked' }
|
||||
|
||||
// Thrown by createShare on invalid share-shape combinations. Carries a stable
|
||||
// code the http layer maps to a 400/404.
|
||||
@@ -68,7 +72,6 @@ export interface ShareRepo {
|
||||
decrementDownloads(shareId: string): Promise<void>
|
||||
listRecipientUserIds(shareId: string): Promise<string[]>
|
||||
cascadeDeleteByMatter(matterId: string): Promise<void>
|
||||
getCreatorByToken(token: string): Promise<string | null>
|
||||
revokeByToken(token: string, creatorId: string): Promise<boolean>
|
||||
listForApi(
|
||||
creatorId: string,
|
||||
|
||||
@@ -178,7 +178,16 @@ describe('resolveDirectShareDownload', () => {
|
||||
})
|
||||
|
||||
it('returns matter_trashed when the share resolves to a trashed matter', async () => {
|
||||
const { deps } = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
|
||||
const { deps } = makeDeps({
|
||||
share: {
|
||||
resolveByToken: async () => ({
|
||||
status: 'matter_trashed',
|
||||
share: sampleShare,
|
||||
matter: sampleMatter,
|
||||
recipients: [],
|
||||
}),
|
||||
},
|
||||
})
|
||||
const out = await resolveDirectShareDownload(deps, { token: 'ds_token1', cloudBaseUrl: CLOUD_BASE_URL })
|
||||
expectError(out, 410, undefined, 'File no longer available')
|
||||
})
|
||||
|
||||
@@ -122,6 +122,10 @@ const okResolution = (
|
||||
over: { share?: ShareRecord; matter?: Matter; recipients?: ShareRecipientRecord[] } = {},
|
||||
): ShareResolution => ({ status: 'ok', share: landingShare, matter: fileMatter, recipients: [], ...over })
|
||||
|
||||
const trashedResolution = (
|
||||
over: { share?: ShareRecord; matter?: Matter; recipients?: ShareRecipientRecord[] } = {},
|
||||
): ShareResolution => ({ status: 'matter_trashed', share: landingShare, matter: fileMatter, recipients: [], ...over })
|
||||
|
||||
function makeShareRepo(over: Partial<ShareRepo> = {}): ShareRepo {
|
||||
return {
|
||||
resolveByToken: async () => okResolution(),
|
||||
@@ -129,7 +133,6 @@ function makeShareRepo(over: Partial<ShareRepo> = {}): ShareRepo {
|
||||
hasDownloadsAvailable: async () => true,
|
||||
incrementDownloadsAtomic: async () => ({ ok: true, downloads: 3 }),
|
||||
decrementDownloads: async () => {},
|
||||
getCreatorByToken: async () => 'creator-1',
|
||||
revokeByToken: async () => true,
|
||||
listForApi: async () => ({ items: [], total: 0 }),
|
||||
listReceivedForApi: async () => ({ items: [], total: 0 }),
|
||||
@@ -216,7 +219,7 @@ beforeEach(() => {
|
||||
|
||||
describe('viewShare', () => {
|
||||
it('returns matter_trashed when the matter is trashed', async () => {
|
||||
const { deps } = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
|
||||
const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
|
||||
expectError(
|
||||
await viewShare(deps, { token: 't', viewerId: null, viewCookie: undefined, accessCookie: undefined }),
|
||||
410,
|
||||
@@ -419,7 +422,7 @@ describe('listShareObjects', () => {
|
||||
const baseParams = { token: 'sk_token1', viewerId: null, accessCookie: 'ok', relativePath: '', page: 1, pageSize: 50 }
|
||||
|
||||
it('returns matter_trashed / not_found from resolution', async () => {
|
||||
const trashed = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
|
||||
const trashed = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
|
||||
expectError(await listShareObjects(trashed.deps, baseParams), 410, undefined, 'File no longer available')
|
||||
|
||||
const revoked = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } })
|
||||
@@ -585,7 +588,7 @@ describe('downloadShareObject', () => {
|
||||
})
|
||||
|
||||
it('returns matter_trashed / not_found from resolution', async () => {
|
||||
const trashed = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
|
||||
const trashed = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
|
||||
expectError(await downloadShareObject(trashed.deps, baseParams), 410, undefined, 'File no longer available')
|
||||
const revoked = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } })
|
||||
expectError(await downloadShareObject(revoked.deps, baseParams), 404, undefined, 'File not found or not accessible')
|
||||
@@ -871,29 +874,75 @@ describe('createShare', () => {
|
||||
// ─── revokeShare ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('revokeShare', () => {
|
||||
it('returns not_found when the token has no creator', async () => {
|
||||
const { deps, record } = makeDeps({ share: { getCreatorByToken: async () => null } })
|
||||
expectError(await revokeShare(deps, { token: 't', userId: 'u1', orgId: 'o-1' }), 404, undefined, 'Not found')
|
||||
it('returns not_found when the token does not resolve', async () => {
|
||||
const { deps, record } = makeDeps({ share: { resolveByToken: async () => ({ status: 'not_found' }) } })
|
||||
expectError(await revokeShare(deps, { token: 't', userId: 'creator-1', orgId: 'o-1' }), 404, undefined, 'Not found')
|
||||
expect(record).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns not_found when the share is already revoked', async () => {
|
||||
const { deps, record } = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } })
|
||||
expectError(await revokeShare(deps, { token: 't', userId: 'creator-1', orgId: 'o-1' }), 404, undefined, 'Not found')
|
||||
expect(record).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns forbidden when the requester is not the creator', async () => {
|
||||
const { deps } = makeDeps({ share: { getCreatorByToken: async () => 'someone-else' } })
|
||||
expectError(await revokeShare(deps, { token: 't', userId: 'u1', orgId: 'o-1' }), 403, undefined, 'Forbidden')
|
||||
const { deps } = makeDeps()
|
||||
expectError(
|
||||
await revokeShare(deps, { token: 't', userId: 'someone-else', orgId: 'o-1' }),
|
||||
403,
|
||||
undefined,
|
||||
'Forbidden',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns not_found when the scoped revoke loses the race', async () => {
|
||||
const { deps } = makeDeps({ share: { getCreatorByToken: async () => 'u1', revokeByToken: async () => false } })
|
||||
expectError(await revokeShare(deps, { token: 't', userId: 'u1', orgId: 'o-1' }), 404, undefined, 'Not found')
|
||||
const { deps } = makeDeps({ share: { revokeByToken: async () => false } })
|
||||
expectError(
|
||||
await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1', orgId: 'o-1' }),
|
||||
404,
|
||||
undefined,
|
||||
'Not found',
|
||||
)
|
||||
})
|
||||
|
||||
it('revokes and records activity on success', async () => {
|
||||
it('still revokes a share whose matter is trashed (trashing does not cascade)', async () => {
|
||||
const revokeByToken = vi.fn(async () => true)
|
||||
const { deps, record } = makeDeps({ share: { getCreatorByToken: async () => 'u1', revokeByToken } })
|
||||
expect(await revokeShare(deps, { token: 'tok', userId: 'u1', orgId: 'o-1' })).toEqual({ ok: true })
|
||||
expect(revokeByToken).toHaveBeenCalledWith('tok', 'u1')
|
||||
const { deps, record } = makeDeps({ share: { resolveByToken: async () => trashedResolution(), revokeByToken } })
|
||||
const out = await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1', orgId: 'o-1' })
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) throw new Error('expected ok')
|
||||
expect(out.dto).toMatchObject({ token: 'sk_token1', status: 'revoked', id: 's-1', creatorId: 'creator-1' })
|
||||
expect(revokeByToken).toHaveBeenCalledWith('sk_token1', 'creator-1')
|
||||
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'share_revoke', targetName: 'sk_token1' }))
|
||||
})
|
||||
|
||||
it('returns forbidden for a non-creator even when the matter is trashed', async () => {
|
||||
const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
|
||||
expectError(
|
||||
await revokeShare(deps, { token: 'sk_token1', userId: 'someone-else', orgId: 'o-1' }),
|
||||
403,
|
||||
undefined,
|
||||
'Forbidden',
|
||||
)
|
||||
})
|
||||
|
||||
it('revokes, records activity, and returns the revoked creator view', async () => {
|
||||
const revokeByToken = vi.fn(async () => true)
|
||||
const { deps, record } = makeDeps({ share: { revokeByToken } })
|
||||
const out = await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1', orgId: 'o-1' })
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) throw new Error('expected ok')
|
||||
expect(out.dto).toMatchObject({
|
||||
token: 'sk_token1',
|
||||
status: 'revoked',
|
||||
id: 's-1',
|
||||
creatorId: 'creator-1',
|
||||
recipients: [],
|
||||
})
|
||||
expect(revokeByToken).toHaveBeenCalledWith('sk_token1', 'creator-1')
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'share_revoke', targetName: 'tok', orgId: 'o-1', userId: 'u1' }),
|
||||
expect.objectContaining({ action: 'share_revoke', targetName: 'sk_token1', orgId: 'o-1', userId: 'creator-1' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -910,7 +959,7 @@ describe('saveShare', () => {
|
||||
}
|
||||
|
||||
it('returns matter_trashed when the share target was trashed', async () => {
|
||||
const { deps } = makeDeps({ share: { resolveByToken: async () => ({ status: 'matter_trashed' }) } })
|
||||
const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution() } })
|
||||
expectError(await saveShare(deps, baseParams), 410, undefined, 'Share target has been deleted')
|
||||
})
|
||||
|
||||
|
||||
+65
-41
@@ -104,26 +104,18 @@ export type ViewShareOutcome =
|
||||
| { ok: true; dto: ShareViewerDto | ShareCreatorDto; setViewCookie: boolean }
|
||||
| { ok: false; error: AppError }
|
||||
|
||||
export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promise<ViewShareOutcome> {
|
||||
const { token, viewerId, viewCookie, accessCookie, now = new Date() } = params
|
||||
|
||||
const resolved = await deps.share.resolveByToken(token)
|
||||
if (resolved.status !== 'ok') {
|
||||
if (resolved.status === 'matter_trashed') return { ok: false, error: expiredError('File no longer available') }
|
||||
return { ok: false, error: notFound('Share not found or revoked') }
|
||||
}
|
||||
|
||||
// Assemble the share view DTO from resolved share data. The creator (matched by
|
||||
// viewerId) gets the richer ShareCreatorDto; everyone else gets the viewer DTO.
|
||||
// Shared by viewShare and revokeShare so both expose an identically-shaped view.
|
||||
async function composeShareView(
|
||||
deps: ShareDeps,
|
||||
resolved: { share: ShareRecord; matter: Matter; recipients: ShareRecipientRecord[] },
|
||||
opts: { viewerId: string | null; accessCookie: string | undefined; now: Date },
|
||||
): Promise<ShareViewerDto | ShareCreatorDto> {
|
||||
const { share, matter, recipients } = resolved
|
||||
const { viewerId, accessCookie, now } = opts
|
||||
const isCreator = !!viewerId && viewerId === share.creatorId
|
||||
|
||||
// Direct shares are not publicly viewable; only the creator sees metadata.
|
||||
if (share.kind !== 'landing' && !isCreator) return { ok: false, error: notFound('Share not found or revoked') }
|
||||
|
||||
// View-dedup increment: non-creators whose view cookie isn't yet 'seen'. The
|
||||
// handler sets the cookie when setViewCookie is true.
|
||||
const setViewCookie = !isCreator && viewCookie !== 'seen'
|
||||
if (setViewCookie) await deps.share.incrementViews(share.id)
|
||||
|
||||
const accessibleByUser = viewerId ? isAccessibleByUser(recipients, viewerId) : false
|
||||
const requiresPassword = !isCreator && !!(share.passwordHash && !accessibleByUser && accessCookie !== 'ok')
|
||||
const expired = !!(share.expiresAt && share.expiresAt < now)
|
||||
@@ -146,25 +138,45 @@ export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promi
|
||||
accessibleByUser,
|
||||
downloads: share.downloads,
|
||||
views: share.views,
|
||||
rootRef: encodeChildRef(token, matter.id),
|
||||
rootRef: encodeChildRef(share.token, matter.id),
|
||||
}
|
||||
|
||||
if (isCreator) {
|
||||
return {
|
||||
ok: true,
|
||||
setViewCookie,
|
||||
dto: {
|
||||
...base,
|
||||
id: share.id,
|
||||
matterId: share.matterId,
|
||||
orgId: share.orgId,
|
||||
creatorId: share.creatorId,
|
||||
createdAt: share.createdAt,
|
||||
recipients,
|
||||
},
|
||||
...base,
|
||||
id: share.id,
|
||||
matterId: share.matterId,
|
||||
orgId: share.orgId,
|
||||
creatorId: share.creatorId,
|
||||
createdAt: share.createdAt,
|
||||
recipients,
|
||||
}
|
||||
}
|
||||
return { ok: true, setViewCookie, dto: base }
|
||||
return base
|
||||
}
|
||||
|
||||
export async function viewShare(deps: ShareDeps, params: ViewShareParams): Promise<ViewShareOutcome> {
|
||||
const { token, viewerId, viewCookie, accessCookie, now = new Date() } = params
|
||||
|
||||
const resolved = await deps.share.resolveByToken(token)
|
||||
if (resolved.status !== 'ok') {
|
||||
if (resolved.status === 'matter_trashed') return { ok: false, error: expiredError('File no longer available') }
|
||||
return { ok: false, error: notFound('Share not found or revoked') }
|
||||
}
|
||||
|
||||
const { share, matter, recipients } = resolved
|
||||
const isCreator = !!viewerId && viewerId === share.creatorId
|
||||
|
||||
// Direct shares are not publicly viewable; only the creator sees metadata.
|
||||
if (share.kind !== 'landing' && !isCreator) return { ok: false, error: notFound('Share not found or revoked') }
|
||||
|
||||
// View-dedup increment: non-creators whose view cookie isn't yet 'seen'. The
|
||||
// handler sets the cookie when setViewCookie is true.
|
||||
const setViewCookie = !isCreator && viewCookie !== 'seen'
|
||||
if (setViewCookie) await deps.share.incrementViews(share.id)
|
||||
|
||||
const dto = await composeShareView(deps, { share, matter, recipients }, { viewerId, accessCookie, now })
|
||||
return { ok: true, setViewCookie, dto }
|
||||
}
|
||||
|
||||
// ─── POST /:token/sessions — verify password → access-cookie decision ────────
|
||||
@@ -486,22 +498,28 @@ export async function createShare(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DELETE /:token — revoke (ownership-scoped) ──────────────────────────────
|
||||
// ─── PUT /:token/status — revoke (ownership-scoped) ──────────────────────────
|
||||
|
||||
export type RevokeShareParams = { token: string; userId: string; orgId: string }
|
||||
export type RevokeShareParams = { token: string; userId: string; orgId: string; now?: Date }
|
||||
|
||||
export type RevokeShareOutcome = { ok: true } | { ok: false; error: AppError }
|
||||
export type RevokeShareOutcome = { ok: true; dto: ShareViewerDto | ShareCreatorDto } | { ok: false; error: AppError }
|
||||
|
||||
export async function revokeShare(deps: ShareDeps, params: RevokeShareParams): Promise<RevokeShareOutcome> {
|
||||
const { token, userId, orgId } = params
|
||||
const { token, userId, orgId, now = new Date() } = params
|
||||
|
||||
const creatorId = await deps.share.getCreatorByToken(token)
|
||||
if (creatorId === null) return { ok: false, error: notFound() }
|
||||
if (creatorId !== userId) return { ok: false, error: forbidden() }
|
||||
// Resolve before revoking: once the status flips to 'revoked', resolveByToken
|
||||
// no longer returns the record, so we capture the share here to build the
|
||||
// creator view. Unknown or already-revoked tokens are "not found" to the
|
||||
// revoker. A trashed matter still carries the records: the owner must be able
|
||||
// to revoke a share whose target was soft-deleted (trashing does not cascade
|
||||
// to shares), so this path stays revocable.
|
||||
const resolved = await deps.share.resolveByToken(token)
|
||||
if (resolved.status === 'not_found' || resolved.status === 'revoked') return { ok: false, error: notFound() }
|
||||
if (resolved.share.creatorId !== userId) return { ok: false, error: forbidden() }
|
||||
|
||||
// Race-safe: revokeByToken scopes the UPDATE to (token, creatorId). A
|
||||
// concurrent revoke or ownership change between the check above and this call
|
||||
// returns false — translate to not_found at the boundary.
|
||||
// Race-safe: revokeByToken scopes the UPDATE to (token, creatorId). An
|
||||
// ownership change between the resolve above and this call returns false —
|
||||
// translate to not_found at the boundary.
|
||||
const revoked = await deps.share.revokeByToken(token, userId)
|
||||
if (!revoked) return { ok: false, error: notFound() }
|
||||
|
||||
@@ -513,7 +531,13 @@ export async function revokeShare(deps: ShareDeps, params: RevokeShareParams): P
|
||||
targetName: token,
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
// Return the creator view reflecting the post-revoke state.
|
||||
const dto = await composeShareView(
|
||||
deps,
|
||||
{ share: { ...resolved.share, status: 'revoked' }, matter: resolved.matter, recipients: resolved.recipients },
|
||||
{ viewerId: userId, accessCookie: undefined, now },
|
||||
)
|
||||
return { ok: true, dto }
|
||||
}
|
||||
|
||||
// ─── POST /:token/objects — save-to-drive (gates + copy) ─────────────────────
|
||||
|
||||
+12
-6
@@ -171,18 +171,24 @@ Feature: Shares
|
||||
When they save a share there
|
||||
Then the API responds 403
|
||||
|
||||
@shares/delete @api
|
||||
@shares/revoke @api
|
||||
Scenario: A creator revokes their share
|
||||
Given a creator's share
|
||||
When they delete it
|
||||
Then its status becomes revoked
|
||||
When they set its status to revoked
|
||||
Then the revoked share view is returned and its status becomes revoked
|
||||
|
||||
@shares/delete-non-creator @api
|
||||
Scenario: Non-creators cannot delete a share
|
||||
@shares/revoke-non-creator @api
|
||||
Scenario: Non-creators cannot revoke a share
|
||||
Given a share owned by someone else
|
||||
When a non-creator deletes it
|
||||
When a non-creator sets its status to revoked
|
||||
Then the API responds 403
|
||||
|
||||
@shares/revoke-trashed-matter @api
|
||||
Scenario: A creator can revoke a share whose file was trashed
|
||||
Given a creator's share whose matter is trashed but not purged
|
||||
When they set its status to revoked
|
||||
Then the revoked share view is returned and its status becomes revoked
|
||||
|
||||
@shares/received-list @api
|
||||
Scenario: Users see shares addressed to them
|
||||
Given shares addressed by id and by email
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Integration-project tests for src/lib/api.ts share wrapper functions.
|
||||
// These run in the integration vitest project so codecov picks them up for patch coverage.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, deleteShare, getShare, listShares } from './api'
|
||||
import { ApiError, getShare, listShares, revokeShare } from './api'
|
||||
|
||||
function makeResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
@@ -73,20 +73,22 @@ describe('shares API wrappers (integration)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteShare', () => {
|
||||
it('calls DELETE /api/shares/:id and resolves on 204', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response)
|
||||
describe('revokeShare', () => {
|
||||
it('puts status: revoked to /api/shares/:id/status and resolves with the share', async () => {
|
||||
const payload = { token: 'share-1', status: 'revoked', kind: 'landing' }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
await expect(deleteShare('share-1')).resolves.toBeUndefined()
|
||||
await expect(revokeShare('share-1')).resolves.toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/shares/share-1')
|
||||
expect(init.method).toBe('DELETE')
|
||||
expect(url).toContain('/api/shares/share-1/status')
|
||||
expect(init.method).toBe('PUT')
|
||||
expect(JSON.parse(init.body as string)).toEqual({ status: 'revoked' })
|
||||
})
|
||||
|
||||
it('throws ApiError on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden' } as Response)
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
||||
|
||||
await expect(deleteShare('share-1')).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(revokeShare('share-1')).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+18
-11
@@ -35,7 +35,6 @@ import {
|
||||
deleteIhostImage,
|
||||
deleteInviteCode,
|
||||
deleteObject,
|
||||
deleteShare,
|
||||
deleteStorage,
|
||||
deleteTeamLogo,
|
||||
disconnectCloud,
|
||||
@@ -112,6 +111,7 @@ import {
|
||||
revokeIhostApiKey,
|
||||
revokeOrgEntitlement,
|
||||
revokeRemoteDownloadApiKey,
|
||||
revokeShare,
|
||||
revokeSiteInvitation,
|
||||
revokeUserEntitlement,
|
||||
revokeWebDavAppPassword,
|
||||
@@ -425,8 +425,8 @@ describe('api', () => {
|
||||
expect(JSON.parse(calls[2][1].body as string)).toEqual({ code: 'GIFT-123' })
|
||||
expect(calls[3][0]).toBe('/api/store/orders/order-1/payments')
|
||||
expect(calls[3][1].method).toBe('POST')
|
||||
expect(calls[4][0]).toBe('/api/store/orders/order-1')
|
||||
expect(calls[4][1].method).toBe('PATCH')
|
||||
expect(calls[4][0]).toBe('/api/store/orders/order-1/status')
|
||||
expect(calls[4][1].method).toBe('PUT')
|
||||
expect(JSON.parse(calls[4][1].body as string)).toEqual({ status: 'canceled' })
|
||||
})
|
||||
|
||||
@@ -2357,20 +2357,27 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteShare', () => {
|
||||
it('calls DELETE /api/shares/:token and resolves on 204', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response)
|
||||
describe('revokeShare', () => {
|
||||
it('puts status: revoked to /api/shares/:token/status and resolves with the revoked share', async () => {
|
||||
const payload = { token: 'tok123', status: 'revoked', kind: 'landing' }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
await expect(deleteShare('tok123')).resolves.toBeUndefined()
|
||||
const result = await revokeShare('tok123')
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/shares/tok123')
|
||||
expect(init.method).toBe('DELETE')
|
||||
expect(url).toContain('/api/shares/tok123/status')
|
||||
expect(init.method).toBe('PUT')
|
||||
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
||||
expect(body).toEqual({ status: 'revoked' })
|
||||
})
|
||||
|
||||
it('throws ApiError on non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden' } as Response)
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
||||
|
||||
await expect(deleteShare('tok123')).rejects.toThrow('Forbidden')
|
||||
await expect(revokeShare('tok123')).rejects.toThrow('Forbidden')
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
|
||||
await expect(revokeShare('tok123')).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+3
-5
@@ -611,7 +611,7 @@ export function continueCloudOrderPayment(orderId: string) {
|
||||
|
||||
export function cancelCloudOrder(orderId: string) {
|
||||
return unwrap<CloudOrder>(
|
||||
cloudStoreApi.orders[':orderId'].$patch({ param: { orderId }, json: { status: 'canceled' } }),
|
||||
cloudStoreApi.orders[':orderId'].status.$put({ param: { orderId }, json: { status: 'canceled' } }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -885,10 +885,8 @@ export function getShare(token: string) {
|
||||
return unwrap<ShareView>(publicSharesApi[':token'].$get({ param: { token } }))
|
||||
}
|
||||
|
||||
export function deleteShare(token: string) {
|
||||
return authedSharesApi[':token'].$delete({ param: { token } }).then((res) => {
|
||||
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
|
||||
})
|
||||
export function revokeShare(token: string) {
|
||||
return unwrap<ShareView>(authedSharesApi[':token'].status.$put({ param: { token }, json: { status: 'revoked' } }))
|
||||
}
|
||||
|
||||
export interface CreateShareResult {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useClipboard } from '@/hooks/use-clipboard'
|
||||
import { deleteShare, listReceivedShares, listShares, type ShareListItem } from '@/lib/api'
|
||||
import { listReceivedShares, listShares, revokeShare, type ShareListItem } from '@/lib/api'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/shares/')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@@ -65,7 +65,7 @@ function SharesPage() {
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (token: string) => deleteShare(token),
|
||||
mutationFn: (token: string) => revokeShare(token),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shares'] })
|
||||
toast.success(t('shares.revokeSuccess'))
|
||||
|
||||
Reference in New Issue
Block a user