Skip to content

Bug 636342: External Storage - Document Attachments does not delete blobs on attachment delete#8392

Merged
Groenbech96 merged 5 commits into
mainfrom
magnushar/fix-636342-external-storage-delete
Jun 3, 2026
Merged

Bug 636342: External Storage - Document Attachments does not delete blobs on attachment delete#8392
Groenbech96 merged 5 commits into
mainfrom
magnushar/fix-636342-external-storage-delete

Conversation

@Groenbech96
Copy link
Copy Markdown
Contributor

@Groenbech96 Groenbech96 commented Jun 1, 2026

Fixes AB#636342External Storage – Document Attachments does not delete attachments on external storage.

The bug

When a row in Table 1173 "Document Attachment" is deleted in a BC28 environment that has the External Storage – Document Attachments app installed and configured with the default setup record, the corresponding blob in the configured Azure Blob container is not deleted. No error or warning surfaces to the user. The opt-in setting "Delete from External Storage" = true (default) is non-functional on the automatic deletion path. Every deleted attachment silently orphans its blob, so customer Azure storage bills grow monotonically.

Customer impact:

  • Silent data leak with monetary cost
  • Affects 100% of customers who enable the feature — the buggy code path is unconditional
  • Present since the feature first shipped (External Storage - Document Attachments #4495, 2026-02-12) — original-design defect, not a regression
  • Linked IcM: 51000001022543

Root cause

In App/src/DocumentAttachmentIntegration/DAExternalStorageImpl.Codeunit.al:

The OnAfterDeleteDocumentAttachment subscriber fires on Database::"Document Attachment", OnAfterDeleteEvent. By the time it runs, the row has already been deleted. The subscriber called ExternalStorageImpl.DeleteFromExternalStorage(Rec), whose first non-trivial line was:

if not DocumentAttachment.Find() then
    exit(false);

Find() always returned false for the deleted row, so the procedure exited before it ever reached ExternalFileStorage.DeleteFile(ExternalFilePath) or the LogFileDeleted telemetry call. A second latent defect: MarkAsNotUploadedToExternal() calls Modify(), which would also fail on a deleted row. The fix has to avoid both Find() and Modify() on the subscriber's code path.

A third dead-code defect: after the (silently failing) delete call, the subscriber attempted to clear Rec."Skip Delete On Copy" via Rec.Modify() — also impossible on a deleted row, and pointless because the row is gone.

The fix

  1. Extract the actual file-delete + telemetry into a private helper TryDeleteExternalFile(ExternalFilePath; DocumentAttachmentForTelemetry). No Find(), no Modify(), no record-state assumptions — just GetSpecificFileAccountDeleteFileLogFileDeleted.
  2. Refactor DeleteFromExternalStorage (used by the page action and the sync report, where the row is still live) to delegate the blob-delete + telemetry to the helper and then call MarkAsNotUploadedToExternal() on success. Behavior preserved for those callers.
  3. Rewrite the OnAfterDeleteEvent subscriber to gate on Rec's still-populated field values (Stored Externally, External File Path, Skip Delete On Copy, IsFileFromAnotherEnvironmentOrCompany) and call the helper directly with Rec."External File Path". Remove the dead Modify() block.

The choice to keep one helper instead of duplicating a "by path" overload follows the existing convention for this internal codeunit — one method per concern, no parameter-only overloads in an internal impl.

Tests

New tests in Test/src/DAExtStorageImplTests.Codeunit.al, region OnAfterDelete Subscriber Tests:

  • RecordDeleteRemovesBlobFromExternalStorage — the direct regression test. Upload a file, delete the row, assert the blob is gone via CheckIfFileExistInExternalStorage. Would have failed before the fix.
  • RecordDeleteKeepsBlobWhenSkipDeleteOnCopyIsSet — guards the copied-attachment case: when a row is created via the attachment-copy flow, Skip Delete On Copy is set and the shared source blob must NOT be deleted when the copy is removed.
  • RecordDeleteKeepsBlobWhenDeleteFromExternalStorageDisabled — guards the user opt-out: when the setting is off, deleting the row must not touch the blob.

The existing DeleteFromExternalSucceedsForUploadedFile, DeleteFailsWhenFeatureDisabled, and DeleteSkippedWhenSkipDeleteOnCopyIsSet tests still cover the manual DeleteFromExternalStorage entry point used by the page action and the sync report.

Risk

Low. The TryDeleteExternalFile helper is private to the codeunit. DeleteFromExternalStorage's observable behavior is unchanged for its two existing callers (DocumentAttachmentExternal.Page.al:202, DAExternalStorageSync.Report.al:75) — same gating, same MarkAsNotUploadedToExternal on success, same telemetry. Only the subscriber's behavior changes, and that change is from "silently does nothing" to "actually deletes the blob," which is the documented and intended behavior. The fix is bounded to one app; no platform, no other app, no schema change.

🤖 Generated with Claude Code

…delete

The OnAfterDeleteEvent subscriber called DeleteFromExternalStorage(Rec),
which started with Rec.Find() and exited as soon as that returned false
because the row had already been deleted. The blob in Azure was never
removed, telemetry tag 0000RNT never fired, and customers' Azure storage
bills grew silently for every deleted attachment.

Extract the blob-delete + telemetry logic into a path-based helper, keep
DeleteFromExternalStorage for callers that still hold a live row (page
action, sync report), and rewrite the OnAfterDelete subscriber to gate
on Rec's field values (Stored Externally, External File Path, Skip
Delete On Copy, Source Environment Hash) and call the helper directly,
without Find() or Modify(). Also drop the dead post-call Modify() block
that attempted to clear Skip Delete On Copy on the already-deleted row.

Add regression tests covering the subscriber path: a delete with the
feature enabled removes the blob; Skip Delete On Copy preserves the
shared blob from a copied attachment; "Delete from External Storage =
false" preserves the blob.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Groenbech96 Groenbech96 requested a review from a team as a code owner June 1, 2026 13:03
@github-actions github-actions Bot added the AL: Apps (W1) Add-on apps for W1 label Jun 1, 2026
@github-actions github-actions Bot added this to the Version 29.0 milestone Jun 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

$\textbf{🟡\ Medium\ Severity\ —\ Style} \quad \color{gray}{\texttt{\small Iteration\ 1}}$

Missing test for cross-environment guard in OnAfterDelete

The new IsFileFromAnotherEnvironmentOrCompany early-exit branch added to OnAfterDeleteDocumentAttachment (line 832 of the impl) has no corresponding test in the new OnAfterDelete Subscriber Tests region, leaving that code path untested.

Recommendation:

  • Add a fourth test such as RecordDeleteKeepsBlobWhenFileIsFromAnotherEnvironment that sets up a cross-environment or cross-company attachment and asserts the blob is preserved after the row is deleted.
[Test]
[HandlerFunctions('ConfirmYesHandler')]
procedure RecordDeleteKeepsBlobWhenFileIsFromAnotherEnvironment()
var
    DocumentAttachment: Record "Document Attachment";
    DAExternalStorageImpl: Codeunit "DA External Storage Impl.";
    ExternalFilePath: Text;
begin
    // [SCENARIO] Files owned by another environment/company must not be deleted
    // when the local attachment row is removed.
    Initialize();
    SetupFileScenarioWithTestConnector();
    EnableFeatureWithDelete();

    // [GIVEN] An externally-stored attachment whose path belongs to another environment
    CreateDocumentAttachmentWithContent(DocumentAttachment);
    DAExternalStorageImpl.UploadToExternalStorage(DocumentAttachment);
    DocumentAttachment.SetRecFilter();
    DocumentAttachment.FindFirst();
    // Simulate cross-environment ownership by setting a foreign path prefix
    DocumentAttachment."External File Path" := 'other-env/' + DocumentAttachment."External File Path";
    DocumentAttachment.Modify();
    ExternalFilePath := DocumentAttachment."External File Path";

    // [WHEN] The row is deleted
    DocumentAttachment.Delete(true);

    // [THEN] The blob is still present (not deleted by this environment)
    Assert.IsTrue(DAExternalStorageImpl.CheckIfFileExistInExternalStorage(ExternalFilePath),
        'Blob should be preserved when the file belongs to another environment or company');
end;

Line mapping was unavailable, so this was posted as an issue comment.

👍 useful · ❤️ especially valuable · 👎 wrong - reply with why

Covers the IsFileFromAnotherEnvironmentOrCompany early-exit branch in
OnAfterDeleteDocumentAttachment - blobs owned by another environment
must not be deleted by the local environment's row-delete trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Groenbech96
Copy link
Copy Markdown
Contributor Author

Thanks @github-actions — added RecordDeleteKeepsBlobWhenFileIsFromAnotherEnvironment in fa7d079. The intent in your suggestion was right but the trigger needed adjusting: IsFileFromAnotherEnvironmentOrCompany keys off Source Environment Hash, not the path (see the existing IsFileFromAnotherEnvironmentReturnsTrueForDifferentEnvironment test at line 528), so the new test sets a foreign hash on the row before deleting.

The OnAfterDelete regression tests asserted blob presence via
CheckIfFileExistInExternalStorage, which routes to the Test File Storage
Connector mock. The mock's CreateFile, DeleteFile and FileExists are
empty by design, so FileExists always returned false, failing the
precondition and the post-delete asserts.

Record DeleteFile invocations on Test File Connector Setup
(Last Deleted File Path) and expose them through
FileConnectorMock.GetLastDeletedPath. The four subscriber tests now
assert DeleteFile was (or was not) called with the stored External File
Path, which is the actual contract under test for the subscriber path.
Magnus Hartvig Grønbech and others added 2 commits June 2, 2026 14:00
The platform's External File Storage wraps connector callbacks in a
TryFunction so a connector error can't leave the caller in an
unrecoverable state. The mock connector therefore cannot Modify() a
table from inside DeleteFile() — doing so failed both new subscriber
regression tests with "Call to the function 'MODIFY' is not allowed
inside the call to '...' when it is used as a TryFunction."

Make `Test File Storage Connector` SingleInstance and remember the last
deleted path in a Text global on the codeunit instance, exposed as
GetLastDeletedPath()/ResetLastDeletedPath(). FileConnectorMock now
delegates to it and resets it during Initialize(). Drop the now-unused
"Last Deleted File Path" field from `Test File Connector Setup`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
darjoo: the function doesn't carry the [TryFunction] attribute and any
unhandled error from ExternalFileStorage.DeleteFile() or the file
scenario lookup still propagates, so the Try prefix mis-signalled the
contract. Renamed to DeleteExternalFile to match what it actually does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Groenbech96 Groenbech96 enabled auto-merge (squash) June 3, 2026 07:17
@Groenbech96 Groenbech96 merged commit ec58115 into main Jun 3, 2026
87 of 89 checks passed
@Groenbech96 Groenbech96 deleted the magnushar/fix-636342-external-storage-delete branch June 3, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants