Skip to content

Commit 720d6d3

Browse files
committed
test(dogfood): pin the signed-in download of a file with no attachments scope and no field owner on a booted showcase
Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4f5ce8 commit 720d6d3

1 file changed

Lines changed: 180 additions & 0 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22431] A file with neither an attachments scope nor a field owner — an
4+
// upload no record has claimed, which is how an avatar or an organization logo
5+
// stored as a URL lives — needs a signed-in caller at both download doors,
6+
// over a REAL showcase boot. `acl: 'public_read'` stays the one anonymous
7+
// download (ADR-0104).
8+
//
9+
// The package suite (`service-storage/src/storage-routes.test.ts`) pins the
10+
// gate over a hand-wired resolver. This file is where the composed one runs:
11+
// the plugin's own `kernel:ready` mount binds the kernel's `auth` service as
12+
// the resolver, so the answer a caller gets here is the deployment's answer.
13+
//
14+
// The half that decides whether the change is safe to ship is the COOKIE case.
15+
// A browser renders these files through `<img src>` / `<a href>`, which can
16+
// carry no bearer header — only the session cookie the sign-in set. So the
17+
// signed-in reader is asserted twice: once with the bearer a script sends,
18+
// once with nothing but that cookie, and both must reach the bytes.
19+
//
20+
// Not eligible for the shared showcase project: it boots its own plugins.
21+
22+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+
import { mkdtempSync, promises as fs } from 'node:fs';
24+
import { join } from 'node:path';
25+
import { tmpdir } from 'node:os';
26+
import showcaseStack from '@objectstack/example-showcase';
27+
import { bootStack, type VerifyStack } from '@objectstack/verify';
28+
import { StorageServicePlugin } from '@objectstack/service-storage';
29+
import { showcaseAppDefaultSecurity } from './showcase-security.js';
30+
31+
const SYS = { isSystem: true } as const;
32+
const BYTES = 'unclaimed';
33+
34+
/** Strip the origin off an absolute adapter URL so it can be re-injected. */
35+
const toPath = (url: string): string => url.replace(/^https?:\/\/[^/]+/, '');
36+
37+
describe('[#22431] a download of a file with no attachments scope and no field owner needs a signed-in caller', () => {
38+
let stack: VerifyStack;
39+
let rootDir: string;
40+
let ql: any;
41+
let token: string;
42+
let cookie: string;
43+
/** Uploaded with no scope named — the shape the console's upload adapter sends. */
44+
let unclaimed: string;
45+
let attached: string;
46+
47+
const bearer = () => ({ Authorization: `Bearer ${token}` });
48+
49+
/** The real three-step presigned upload; `scope` omitted unless named. */
50+
const upload = async (name: string, scope?: string): Promise<string> => {
51+
const presign = await stack.api('/storage/upload/presigned', {
52+
method: 'POST',
53+
headers: { 'Content-Type': 'application/json', ...bearer() },
54+
body: JSON.stringify({ filename: name, mimeType: 'text/plain', size: BYTES.length, ...(scope ? { scope } : {}) }),
55+
});
56+
expect(presign.status, 'presign').toBe(200);
57+
const { data } = (await presign.json()) as any;
58+
const put = await stack.raw(toPath(String(data.uploadUrl)), {
59+
method: 'PUT',
60+
headers: data.headers ?? { 'content-type': 'text/plain' },
61+
body: BYTES,
62+
});
63+
expect(put.status, 'raw PUT').toBeLessThan(300);
64+
const complete = await stack.api('/storage/upload/complete', {
65+
method: 'POST',
66+
headers: { 'Content-Type': 'application/json', ...bearer() },
67+
body: JSON.stringify({ fileId: data.fileId }),
68+
});
69+
expect(complete.status, 'complete').toBe(200);
70+
return String(data.fileId);
71+
};
72+
73+
/** Both doors for one caller: the JSON door and the redirect door. */
74+
const doors = async (fileId: string, headers: Record<string, string> = {}) => ({
75+
url: await stack.api(`/storage/files/${fileId}/url`, { headers }),
76+
redirect: await stack.api(`/storage/files/${fileId}`, { headers, redirect: 'manual' } as RequestInit),
77+
});
78+
79+
const expectRefused = async (res: Response, label: string) => {
80+
expect(res.status, label).toBe(401);
81+
const body = (await res.json()) as any;
82+
expect(body.success, label).toBe(false);
83+
expect(body.error?.code, label).toBe('AUTH_REQUIRED');
84+
};
85+
86+
/** A 302 whose target, followed with NO credential, serves the uploaded bytes. */
87+
const expectBytesBehindRedirect = async (res: Response, label: string) => {
88+
expect(res.status, label).toBe(302);
89+
const location = res.headers.get('location');
90+
expect(location, `${label}: a 302 with no Location`).toBeTruthy();
91+
const bytes = await stack.raw(toPath(String(location)));
92+
expect(bytes.status, label).toBe(200);
93+
expect(await bytes.text(), label).toBe(BYTES);
94+
};
95+
96+
beforeAll(async () => {
97+
rootDir = mkdtempSync(join(tmpdir(), 'unclaimed-download-'));
98+
stack = await bootStack(showcaseStack, {
99+
security: showcaseAppDefaultSecurity(),
100+
extraPlugins: [new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false })],
101+
});
102+
ql = await stack.kernel.getServiceAsync('objectql');
103+
token = await stack.signIn();
104+
105+
// The browser transport: the session cookie the sign-in response sets.
106+
const signIn = await stack.api('/auth/sign-in/email', {
107+
method: 'POST',
108+
headers: { 'Content-Type': 'application/json' },
109+
body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }),
110+
});
111+
expect(signIn.status).toBe(200);
112+
cookie = signIn.headers
113+
.getSetCookie()
114+
.map((c) => c.split(';')[0])
115+
.filter((pair) => pair.includes('session_token='))
116+
.join('; ');
117+
expect(cookie, 'the sign-in sets a session cookie').toContain('session_token=');
118+
119+
unclaimed = await upload('unclaimed.txt');
120+
attached = await upload('attached.txt', 'attachments');
121+
}, 120_000);
122+
123+
afterAll(async () => {
124+
await stack?.stop();
125+
if (rootDir) await fs.rm(rootDir, { recursive: true, force: true });
126+
});
127+
128+
it('the file under test is unclaimed: no attachments scope, no field owner, not public_read', async () => {
129+
const row = await ql.findOne('sys_file', { where: { id: unclaimed }, context: SYS });
130+
expect(row?.scope).not.toBe('attachments');
131+
expect(row?.ref_object ?? null).toBeNull();
132+
expect(row?.acl ?? 'private').not.toBe('public_read');
133+
});
134+
135+
it('an anonymous caller is refused 401 AUTH_REQUIRED at both download doors', async () => {
136+
const { url, redirect } = await doors(unclaimed);
137+
await expectRefused(url, 'the URL door');
138+
await expectRefused(redirect, 'the redirect door');
139+
expect(redirect.headers.get('location'), 'no capability URL leaks on the refusal').toBeNull();
140+
});
141+
142+
it('a signed-in caller with a bearer token is served as before', async () => {
143+
const { url, redirect } = await doors(unclaimed, bearer());
144+
expect(url.status).toBe(200);
145+
const body = (await url.json()) as any;
146+
const bytes = await stack.raw(toPath(String(body.data.url)));
147+
expect(await bytes.text()).toBe(BYTES);
148+
await expectBytesBehindRedirect(redirect, 'bearer, redirect door');
149+
});
150+
151+
it('a signed-in browser is served through its session cookie alone — what <img src> carries', async () => {
152+
const { url, redirect } = await doors(unclaimed, { cookie });
153+
expect(url.status, 'the URL door, cookie only').toBe(200);
154+
await expectBytesBehindRedirect(redirect, 'cookie only, redirect door');
155+
});
156+
157+
it("acl: 'public_read' keeps the file anonymous, and only that declaration does", async () => {
158+
await ql.update('sys_file', { acl: 'public_read' }, { where: { id: unclaimed }, context: SYS });
159+
try {
160+
const { url, redirect } = await doors(unclaimed);
161+
expect(url.status, 'public_read, anonymous URL door').toBe(200);
162+
await expectBytesBehindRedirect(redirect, 'public_read, anonymous redirect door');
163+
} finally {
164+
await ql.update('sys_file', { acl: 'private' }, { where: { id: unclaimed }, context: SYS });
165+
}
166+
await expectRefused((await doors(unclaimed)).redirect, 'back to private');
167+
});
168+
169+
it('controls: an anonymous upload and an anonymous attachments-scope download stay refused', async () => {
170+
const presign = await stack.api('/storage/upload/presigned', {
171+
method: 'POST',
172+
headers: { 'Content-Type': 'application/json' },
173+
body: JSON.stringify({ filename: 'anon.txt', mimeType: 'text/plain', size: 1 }),
174+
});
175+
await expectRefused(presign, 'anonymous upload');
176+
const { url, redirect } = await doors(attached);
177+
await expectRefused(url, 'attachments-scope, URL door');
178+
await expectRefused(redirect, 'attachments-scope, redirect door');
179+
});
180+
});

0 commit comments

Comments
 (0)