Repository navigation
fix(presigned-url): no downloadUrl for a file that is not uploaded #1971
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
theothersideofgod
wants to merge
2
commits into
main
Choose a base branch
from
fix/download-url-live-files
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
118 changes: 118 additions & 0 deletions
118
graphql/server-test/__tests__/download-url-status.integration.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| /** | ||
| * Integration tests: `downloadUrl` is handed out only for a file whose bytes a | ||
| * reader can GET. | ||
| * | ||
| * A files row is written before its presigned PUT runs, and the confirm-upload | ||
| * job settles its `status` after inspecting the object. A `requested` row may | ||
| * name an object that never arrives, and a `rejected` row is a settled | ||
| * failure, so neither may carry a download URL. | ||
| * | ||
| * pnpm test -- --testPathPattern=download-url-status | ||
| */ | ||
|
|
||
| import path from 'path'; | ||
| import type { PgTestClient } from 'pgsql-test'; | ||
| import type supertest from 'supertest'; | ||
|
|
||
| import { getConnections, seed } from '../src'; | ||
|
|
||
| jest.setTimeout(120000); | ||
|
|
||
| const localSeedRoot = path.join(__dirname, '..', '__fixtures__', 'seed'); | ||
| const sharedSeedRoot = path.join(__dirname, '..', '..', '..', '__fixtures__', 'seed'); | ||
| const sql = (seedDir: string, file: string) => path.join(localSeedRoot, seedDir, file); | ||
| const pgpmWorkspace = path.join(sharedSeedRoot, '..', '..'); | ||
|
|
||
| const aliceDatabaseId = '80a2eaaf-f77e-4bfe-8506-df929ef1b8d9'; | ||
| const aliceSchema = 'simple-storage-public'; | ||
| const alicePrivateBucketId = 'd0000001-0000-0000-0000-000000000002'; | ||
|
|
||
| const metaSchemas = [ | ||
| 'catalog_private', | ||
| 'routing_public', | ||
| 'apps_public', | ||
| 'metaschema_public', | ||
| 'metaschema_modules_public' | ||
| ]; | ||
|
|
||
| const STATUSES = ['requested', 'uploaded', 'processed', 'rejected']; | ||
|
|
||
| const APP_FILES = ` | ||
| query AppFiles { | ||
| appFiles { | ||
| nodes { | ||
| key | ||
| downloadUrl | ||
| } | ||
| } | ||
| } | ||
| `; | ||
|
|
||
| describe('downloadUrl by file status', () => { | ||
| let request: supertest.Agent; | ||
| let pg: PgTestClient; | ||
| let teardown: () => Promise<void>; | ||
| let urlByKey: Record<string, string | null>; | ||
|
|
||
| beforeAll(async () => { | ||
| ({ request, pg, teardown } = await getConnections( | ||
| { | ||
| schemas: [aliceSchema], | ||
| authRole: 'anonymous', | ||
| server: { | ||
| useRouting: true, | ||
| api: { | ||
| isPublic: false, | ||
| metaSchemas | ||
| } | ||
| } | ||
| }, | ||
| [ | ||
| seed.pgpm(pgpmWorkspace), | ||
| seed.sqlfile([ | ||
| sql('simple-seed-storage', 'schema.sql'), | ||
| sql('simple-seed-storage', 'test-data.sql'), | ||
| sql('simple-seed-storage', 'auth-provision.sql') | ||
| ]) | ||
| ] | ||
| )); | ||
|
|
||
| for (const status of STATUSES) { | ||
| await pg.query( | ||
| `INSERT INTO "${aliceSchema}".app_files | ||
| (bucket_id, key, content_hash, mime_type, filename, status) | ||
| VALUES ($1, $2, $2, 'text/plain', $3, $4)`, | ||
| [alicePrivateBucketId, `status-${status}`, `${status}.txt`, status] | ||
| ); | ||
| } | ||
|
|
||
| const res = await request | ||
| .post('/graphql') | ||
| .set('X-Database-Id', aliceDatabaseId) | ||
| .set('X-Schemata', aliceSchema) | ||
| .send({ query: APP_FILES }); | ||
| expect(res.status).toBe(200); | ||
| expect(res.body.errors).toBeUndefined(); | ||
| urlByKey = Object.fromEntries( | ||
| res.body.data.appFiles.nodes.map((n: { key: string; downloadUrl: string | null }) => [ | ||
| n.key, | ||
| n.downloadUrl | ||
| ]) | ||
| ); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| if (teardown) await teardown(); | ||
| }); | ||
|
|
||
| it.each(['uploaded', 'processed'])('signs a download URL for a %s file', (status) => { | ||
| const url = urlByKey[`status-${status}`]; | ||
| expect(url).toEqual(expect.any(String)); | ||
| expect(new URL(url).pathname).toContain(`/status-${status}`); | ||
| }); | ||
|
|
||
| it.each(['requested', 'rejected'])('returns no download URL for a %s file', (status) => { | ||
| expect(urlByKey).toHaveProperty(`status-${status}`); | ||
| expect(urlByKey[`status-${status}`]).toBeNull(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 bug · medium
statuscolumn assumed on every@storageFilestableThe
downloadUrlplan adds$parent.get('status')and gates onisLiveFileRow({ status }), but the field is attached to every codec carrying the@storageFilessmart tag, not only tables with a lifecycle column (download-url-field.ts:98). A tagged table whose schema lacksstatusfails at plan time (grafast cannot get a nonexistent attribute), turning the graceful null promised in the field description into a broken query. Additionally,isLiveFileRowmatches only the exact stringsuploaded/processed, so a tagged table with a differently-valued lifecycle column would silently return null for every row.📋 Prompt for AI Agents
In graphile/graphile-presigned-url-plugin/src/download-url-field.ts around lines 68-98, before adding the
downloadUrlfield, verify the codec actually has astatusattribute (e.g.if (!pgCodec.attributes?.status) return fields;alongside the existingtags?.storageFilescheck, or log a warning naming the codec when the tag is present butstatusis missing). If you keep the field for tables withoutstatus, treat an absent status as live soisLiveFileRowgating applies only where the schema declares the column. This prevents a plan-time error or a permanently-nulldownloadUrlon@storageFiles-tagged tables that do not carry the lifecyclestatuscolumn.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Checked both parts.
Missing
statuscolumn: not reachable.@storageFilesis set in one place, constructive-dbstorage_module.sql:1038-1041, and the same generator always createsstatus(:768-777,is_required := true). The one commit that replacedstatuswith aprocessedboolean (4b9078bf847) landed on main together with the commit that restored it as thefile_statusenum (e8d4e51d885), both in constructive-db #996, so no main version tagged a table withoutstatus. Every@storageFilestable on a local cluster (11) hasstatusof typefile_status.Different lifecycle values: real for pre-#996 tenants. Files tables provisioned before 2026-05-04 have
status textwithpending/ready/processing/failed; theirreadyfiles would getnull. Dedup already treats them the same way since #1952. Added an "Existing tenants (R7)" section to the PR description with a question for the owner rather than adding a compatibility mapping here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the detailed analysis. The two-part response is helpful, but neither part addresses the original finding in the diff itself.
Missing
statuscolumn: Your argument is that the tag is always co-generated with the column in constructive-db, so the case cannot arise in practice. That is a valid operational argument, but the plugin code still has no guard — if the tag is ever applied manually or by a different generator, the plan-time error will occur with no warning. The finding asked for a codec-level check (e.g.if (!pgCodec.attributes?.status) return fields;) or at minimum a warning, and the diff does not show that added.Different lifecycle values (pre-#996 tenants): You confirm this is a real issue —
readyfiles on older tenants will returnnullfromdownloadUrl. Deferring to a PR description question does not fix the silent data-correctness bug that the finding flagged.The diff adds
$parent.get('status')andisLiveFileRowgating unconditionally with no codec attribute guard and no compatibility mapping for legacy status values. The finding is not resolved.