Files
insforge--insforge/backend/tests/unit/storage-object-is-visible.test.ts
wehub-resource-sync 3a28426bf4
Lint and Format Check / lint-and-format (push) Failing after 0s
Check Migrations / Check for duplicate migration numbers (push) Failing after 1s
CI Pre-merge Check / CI Pre-merge Check (push) Failing after 2m17s
chore: import upstream snapshot with attribution
2026-07-13 12:23:40 +08:00

796 lines
28 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { Pool, PoolClient } from 'pg';
const loggerMocks = vi.hoisted(() => ({
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}));
vi.mock('@/utils/logger.js', () => ({
default: loggerMocks,
}));
// Mock the database manager so StorageService can be instantiated without
// touching a real pool. The service caches the pool internally; we hand it
// our mock via a controlled getInstance/getPool flow.
vi.mock('@/infra/database/database.manager.js', () => ({
DatabaseManager: {
getInstance: () => ({ getPool: () => mockPool }),
},
}));
let mockPool: Pool;
let calls: Array<{ sql: string; params?: unknown[] }>;
// Per-call queue: each entry is the result for the next .query() call.
let queryResults: Array<{ rows: unknown[]; rowCount: number }>;
function makeMockPool(): Pool {
calls = [];
queryResults = [];
const query = vi.fn(async (sql: string, params?: unknown[]) => {
calls.push({ sql, params });
const result = queryResults.shift() ?? { rows: [], rowCount: 0 };
return result;
});
const client = {
query,
release: vi.fn(),
} as unknown as PoolClient;
return {
query,
connect: vi.fn(async () => client),
} as unknown as Pool;
}
describe('StorageService.getObjectMetadataVisible — RLS-gated visibility check', () => {
beforeEach(async () => {
mockPool = makeMockPool();
loggerMocks.info.mockClear();
loggerMocks.error.mockClear();
loggerMocks.warn.mockClear();
vi.resetModules();
});
it('runs through withUserContext for user callers and returns true when SELECT finds a row', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
// The SELECT 1 returns a row, so getObjectMetadataVisible should return true.
queryResults = [
{ rows: [{ public: false }], rowCount: 1 }, // public bucket check
{ rows: [], rowCount: 0 }, // BEGIN
{ rows: [], rowCount: 0 }, // SET LOCAL ROLE authenticated
{ rows: [], rowCount: 0 }, // set_config(claims)
{ rows: [{ '?column?': 1 }], rowCount: 1 }, // row visible
{ rows: [], rowCount: 0 }, // COMMIT
{ rows: [], rowCount: 0 }, // RESET ROLE
];
const visible = await svc.getObjectMetadataVisible(
{ id: 'alice-sub', email: 'alice@example.com', role: 'authenticated' },
'photos',
'alice/cat.jpg'
);
expect(visible).toBeTruthy();
// Verify the SELECT happened *inside* withUserContext (BEGIN before, COMMIT after).
const sequence = calls.map((c) => c.sql);
expect(sequence[0]).toBe('SELECT public FROM storage.buckets WHERE name = $1');
expect(sequence[1]).toBe('BEGIN');
expect(sequence[2]).toBe('SET LOCAL ROLE authenticated');
expect(calls[3].params?.[0]).toBe('request.jwt.claims');
expect(sequence).toContain('SELECT * FROM storage.objects WHERE bucket = $1 AND key = $2');
expect(sequence[sequence.length - 2]).toBe('COMMIT');
expect(sequence[sequence.length - 1]).toBe('RESET ROLE');
// Verify the SELECT bound bucket and key as parameters.
const selectCall = calls.find(
(c) => c.sql === 'SELECT * FROM storage.objects WHERE bucket = $1 AND key = $2'
);
expect(selectCall?.params).toEqual(['photos', 'alice/cat.jpg']);
});
it('returns false when RLS denies the SELECT (zero rows)', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
// The SELECT returns zero rows — non-owner Bob asking for Alice's key.
queryResults = [
{ rows: [{ public: false }], rowCount: 1 }, // public bucket check
{ rows: [], rowCount: 0 }, // BEGIN
{ rows: [], rowCount: 0 }, // SET LOCAL ROLE authenticated
{ rows: [], rowCount: 0 }, // set_config(claims)
{ rows: [], rowCount: 0 }, // RLS-filtered to empty
{ rows: [], rowCount: 0 }, // COMMIT
{ rows: [], rowCount: 0 }, // RESET ROLE
];
const visible = await svc.getObjectMetadataVisible(
{ id: 'bob-sub', email: 'bob@example.com', role: 'authenticated' },
'photos',
'alice/cat.jpg'
);
expect(visible).toBeNull();
});
it('runs SELECT directly on the pool for API-key callers', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
queryResults = [{ rows: [{ '?column?': 1 }], rowCount: 1 }];
const visible = await svc.getObjectMetadataVisible(undefined, 'photos', 'alice/cat.jpg', true);
expect(visible).toBeTruthy();
// API-key path skips BEGIN/SET ROLE/COMMIT — only the visibility SELECT runs.
expect(calls.map((c) => c.sql)).toEqual([
'SELECT * FROM storage.objects WHERE bucket = $1 AND key = $2',
]);
});
it('runs project_admin JWT callers through root access like API-key callers', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
queryResults = [{ rows: [{ '?column?': 1 }], rowCount: 1 }];
const visible = await svc.getObjectMetadataVisible(
{ id: 'local:admin', role: 'project_admin' },
'photos',
'alice/cat.jpg'
);
expect(visible).toBeTruthy();
expect(calls.map((c) => c.sql)).toEqual([
'SELECT * FROM storage.objects WHERE bucket = $1 AND key = $2',
]);
});
it('reads objects for project_admin JWT callers through root access', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const uploadedAt = new Date('2026-01-01T00:00:00.000Z');
const provider = {
getObject: vi.fn(async () => Buffer.from('hello')),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
queryResults = [
{
rows: [
{
bucket: 'photos',
key: 'alice/cat.jpg',
size: 42,
mime_type: 'image/jpeg',
uploaded_at: uploadedAt,
etag: 'etag-admin',
},
],
rowCount: 1,
},
];
const result = await svc.getObject(
{ id: 'local:admin', role: 'project_admin' },
'photos',
'alice/cat.jpg'
);
expect(result?.file.toString()).toBe('hello');
expect(result?.metadata).toMatchObject({
bucket: 'photos',
key: 'alice/cat.jpg',
size: 42,
mimeType: 'image/jpeg',
uploadedAt,
});
expect(provider.getObject).toHaveBeenCalledWith('photos', 'alice/cat.jpg');
expect(calls.map((c) => c.sql)).toEqual([
'SELECT * FROM storage.objects WHERE bucket = $1 AND key = $2',
]);
});
it('lists objects for project_admin JWT callers through root access', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const uploadedAt = new Date('2026-01-01T00:00:00.000Z');
queryResults = [
{
rows: [
{
bucket: 'photos',
key: 'alice/cat.jpg',
size: 42,
mime_type: 'image/jpeg',
uploaded_at: uploadedAt,
etag: 'etag-admin',
},
],
rowCount: 1,
},
{ rows: [{ count: '1' }], rowCount: 1 },
];
const result = await svc.listObjects(
{ id: 'local:admin', role: 'project_admin' },
'photos',
undefined,
10,
0,
undefined
);
expect(result.total).toBe(1);
expect(result.objects[0]).toMatchObject({
bucket: 'photos',
key: 'alice/cat.jpg',
size: 42,
mimeType: 'image/jpeg',
uploadedAt,
});
expect(calls.map((c) => c.sql)).toEqual([
'SELECT * FROM storage.objects WHERE bucket = $1 ORDER BY key LIMIT $2 OFFSET $3',
'SELECT COUNT(*) as count FROM storage.objects WHERE bucket = $1',
]);
expect(calls.map((c) => c.params)).toEqual([['photos', 10, 0], ['photos']]);
});
it('returns false for private bucket objects without a user context', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
queryResults = [{ rows: [{ public: false }], rowCount: 1 }];
await expect(
svc.getObjectMetadataVisible(undefined, 'photos', 'alice/cat.jpg')
).resolves.toBeNull();
expect(calls).toEqual([
{
sql: 'SELECT public FROM storage.buckets WHERE name = $1',
params: ['photos'],
},
]);
});
it('returns a generic 403 for write-like operations without user context', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const { AppError } = await import('@/utils/errors.js');
const svc = StorageService.getInstance();
await expect(
svc.listObjects(undefined, 'photos', undefined, 100, 0, undefined)
).rejects.toMatchObject({
message: 'Forbidden',
statusCode: 403,
code: 'STORAGE_PERMISSION_DENIED',
});
await expect(
svc.listObjects(undefined, 'photos', undefined, 100, 0, undefined)
).rejects.toBeInstanceOf(AppError);
});
it('checks upload-strategy bucket existence outside the user-context transaction', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
getUploadStrategy: vi.fn(async () => ({
method: 'direct' as const,
uploadUrl: '/upload',
key: 'note.txt',
confirmRequired: false,
})),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
queryResults = [
{ rows: [{ name: 'photos' }], rowCount: 1 }, // root bucket existence check
{ rows: [], rowCount: 0 }, // root object dedup query
{ rows: [{ maxFileSizeMb: 50 }], rowCount: 1 }, // storage config
{ rows: [], rowCount: 0 }, // BEGIN
{ rows: [], rowCount: 0 }, // SET LOCAL ROLE authenticated
{ rows: [], rowCount: 0 }, // set_config(claims)
{ rows: [], rowCount: 0 }, // SAVEPOINT
{ rows: [], rowCount: 1 }, // RLS INSERT probe
{ rows: [], rowCount: 0 }, // ROLLBACK TO SAVEPOINT
{ rows: [], rowCount: 0 }, // RELEASE SAVEPOINT
{ rows: [], rowCount: 0 }, // COMMIT
{ rows: [], rowCount: 0 }, // RESET ROLE
];
const strategy = await svc.getUploadStrategy(
{ id: 'alice-sub', email: 'alice@example.com', role: 'authenticated' },
'photos',
{ filename: 'note.txt', contentType: 'text/plain', size: 8 }
);
expect(strategy).toMatchObject({ method: 'direct', key: 'note.txt' });
expect(calls[0]).toEqual({
sql: 'SELECT 1 FROM storage.buckets WHERE name = $1 LIMIT 1',
params: ['photos'],
});
expect(calls[1].sql).toContain('SELECT key FROM storage.objects');
expect(calls[3].sql).toBe('BEGIN');
expect(calls[4].sql).toBe('SET LOCAL ROLE authenticated');
expect(calls.map((c) => c.sql).slice(1)).not.toContain(
'SELECT 1 FROM storage.buckets WHERE name = $1 LIMIT 1'
);
expect(calls.map((c) => c.sql)).toContain('SAVEPOINT upload_strategy_rls_probe');
expect(calls.map((c) => c.sql)).toContain('ROLLBACK TO SAVEPOINT upload_strategy_rls_probe');
expect(calls.some((c) => c.sql.includes('INSERT INTO storage.objects'))).toBe(true);
});
it('skips the upload-strategy RLS insert probe for project_admin JWT callers', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
getUploadStrategy: vi.fn(async () => ({
method: 'direct' as const,
uploadUrl: '/upload',
key: 'note.txt',
confirmRequired: false,
})),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
queryResults = [
{ rows: [{ name: 'photos' }], rowCount: 1 }, // root bucket existence check
{ rows: [], rowCount: 0 }, // root object dedup query
{ rows: [{ maxFileSizeMb: 50 }], rowCount: 1 }, // storage config
];
const strategy = await svc.getUploadStrategy(
{ id: 'local:admin', role: 'project_admin' },
'photos',
{ filename: 'note.txt', contentType: 'text/plain', size: 8 }
);
expect(strategy).toMatchObject({ method: 'direct', key: 'note.txt' });
expect(calls.map((c) => c.sql)).not.toContain('BEGIN');
expect(calls.map((c) => c.sql)).not.toContain('SET LOCAL ROLE project_admin');
expect(calls.some((c) => c.sql.includes('INSERT INTO storage.objects'))).toBe(false);
});
it('uploads objects for project_admin JWT callers through root access', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
putObject: vi.fn(async () => ({ etag: 'etag-admin-upload' })),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
// uploaded_at is supplied app-side, so the INSERT no longer uses RETURNING.
queryResults = [
{ rows: [], rowCount: 0 }, // root object dedup query
{ rows: [], rowCount: 1 }, // root insert (no RETURNING)
{ rows: [], rowCount: 1 }, // root etag update
];
const result = await svc.putObject(
{ id: 'local:admin', role: 'project_admin' },
'photos',
'note.txt',
{ size: 8, mimetype: 'text/plain' } as Express.Multer.File
);
expect(result).toMatchObject({
bucket: 'photos',
key: 'note.txt',
size: 8,
mimeType: 'text/plain',
});
// uploaded_at is an ISO string supplied app-side; assert it round-trips.
expect(new Date(result.uploadedAt).toISOString()).toBe(result.uploadedAt);
expect(provider.putObject).toHaveBeenCalledOnce();
expect(calls.map((c) => c.sql)).not.toContain('BEGIN');
expect(calls.map((c) => c.sql)).not.toContain('SET LOCAL ROLE project_admin');
expect(calls.some((c) => c.sql.includes('INSERT INTO storage.objects'))).toBe(true);
// The insert must not use RETURNING — that re-couples writes to SELECT RLS.
expect(calls.some((c) => /RETURNING/i.test(c.sql))).toBe(false);
});
it('confirms presigned uploads for project_admin JWT callers through root access', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
verifyObjectExists: vi.fn(async () => ({
exists: true,
size: 8,
etag: 'etag-confirmed',
})),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
// uploaded_at is supplied app-side, so the INSERT no longer uses RETURNING.
queryResults = [
{ rows: [{ maxFileSizeMb: 50 }], rowCount: 1 }, // storage config
{ rows: [], rowCount: 0 }, // already-confirmed check
{ rows: [], rowCount: 1 }, // root insert (no RETURNING)
];
const result = await svc.confirmUpload(
{ id: 'local:admin', role: 'project_admin' },
'photos',
'note.txt',
{ size: 8, contentType: 'text/plain' }
);
expect(result).toMatchObject({
bucket: 'photos',
key: 'note.txt',
size: 8,
mimeType: 'text/plain',
});
// uploaded_at is an ISO string supplied app-side; assert it round-trips.
expect(new Date(result.uploadedAt).toISOString()).toBe(result.uploadedAt);
expect(calls.map((c) => c.sql)).not.toContain('BEGIN');
expect(calls.map((c) => c.sql)).not.toContain('SET LOCAL ROLE project_admin');
expect(calls.some((c) => c.sql.includes('INSERT INTO storage.objects'))).toBe(true);
// The insert must not use RETURNING — that re-couples writes to SELECT RLS.
expect(calls.some((c) => /RETURNING/i.test(c.sql))).toBe(false);
});
it('deletes objects for project_admin JWT callers through root access', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
deleteObjects: vi.fn(async () => ({ deleted: ['note.txt'], failed: [] })),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
queryResults = [{ rows: [{ key: 'note.txt' }], rowCount: 1 }];
const deleted = await svc.deleteObject(
{ id: 'local:admin', role: 'project_admin' },
'photos',
'note.txt'
);
expect(deleted).toBe(true);
expect(provider.deleteObjects).toHaveBeenCalledWith('photos', ['note.txt']);
expect(calls.map((c) => c.sql)).toEqual([
'DELETE FROM storage.objects WHERE bucket = $1 AND key = ANY($2::text[]) RETURNING key',
]);
});
it('returns true for single deletes after the DB row is deleted even when provider cleanup fails', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
deleteObjects: vi.fn(async () => ({
deleted: [],
failed: [{ key: 'note.txt', message: 'provider denied' }],
})),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
queryResults = [{ rows: [{ key: 'note.txt' }], rowCount: 1 }];
const deleted = await svc.deleteObject(
{ id: 'local:admin', role: 'project_admin' },
'photos',
'note.txt'
);
expect(deleted).toBe(true);
expect(provider.deleteObjects).toHaveBeenCalledWith('photos', ['note.txt']);
expect(loggerMocks.warn).toHaveBeenCalledWith(
'Storage provider batch delete partially failed after DB rows were deleted',
{
bucket: 'photos',
failed: [{ key: 'note.txt', message: 'provider denied' }],
}
);
});
it('batch deletes objects for project_admin JWT callers through root access', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
deleteObjects: vi.fn(async () => ({
deleted: ['a.txt'],
failed: [{ key: 'b.txt', message: 'provider denied' }],
})),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
queryResults = [{ rows: [{ key: 'a.txt' }, { key: 'b.txt' }], rowCount: 2 }];
const result = await svc.deleteObjects({ id: 'local:admin', role: 'project_admin' }, 'photos', [
'a.txt',
'b.txt',
'missing.txt',
'a.txt',
]);
expect(result).toEqual({
results: [
{ key: 'a.txt', status: 'deleted' },
{ key: 'b.txt', status: 'failed', message: 'provider denied' },
{ key: 'missing.txt', status: 'notFound' },
],
});
expect(provider.deleteObjects).toHaveBeenCalledWith('photos', ['a.txt', 'b.txt']);
expect(calls.map((c) => c.sql)).toEqual([
'DELETE FROM storage.objects WHERE bucket = $1 AND key = ANY($2::text[]) RETURNING key',
]);
expect(calls[0].params).toEqual(['photos', ['a.txt', 'b.txt', 'missing.txt']]);
});
it('batch deletes objects through withUserContext for authenticated callers', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
deleteObjects: vi.fn(async () => ({ deleted: ['mine.txt'], failed: [] })),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
queryResults = [
{ rows: [], rowCount: 0 }, // BEGIN
{ rows: [], rowCount: 0 }, // SET LOCAL ROLE authenticated
{ rows: [], rowCount: 0 }, // set_config(claims)
{ rows: [{ key: 'mine.txt' }], rowCount: 1 }, // RLS-authorized delete
{ rows: [], rowCount: 0 }, // COMMIT
{ rows: [], rowCount: 0 }, // RESET ROLE
];
const result = await svc.deleteObjects(
{ id: 'alice-sub', email: 'alice@example.com', role: 'authenticated' },
'photos',
['mine.txt', 'not-mine.txt']
);
expect(result).toEqual({
results: [
{ key: 'mine.txt', status: 'deleted' },
{ key: 'not-mine.txt', status: 'notFound' },
],
});
expect(provider.deleteObjects).toHaveBeenCalledWith('photos', ['mine.txt']);
expect(calls.map((c) => c.sql)).toEqual([
'BEGIN',
'SET LOCAL ROLE authenticated',
'SELECT set_config($1, $2, true)',
'DELETE FROM storage.objects WHERE bucket = $1 AND key = ANY($2::text[]) RETURNING key',
'COMMIT',
'RESET ROLE',
]);
});
it('reports DB-deleted objects as failed when provider result omits them', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
deleteObjects: vi.fn(async () => ({
deleted: ['a.txt'],
failed: [],
})),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
queryResults = [{ rows: [{ key: 'a.txt' }, { key: 'b.txt' }], rowCount: 2 }];
const result = await svc.deleteObjects({ id: 'local:admin', role: 'project_admin' }, 'photos', [
'a.txt',
'b.txt',
]);
expect(result).toEqual({
results: [
{ key: 'a.txt', status: 'deleted' },
{ key: 'b.txt', status: 'failed', message: 'Failed to delete object' },
],
});
expect(loggerMocks.warn).toHaveBeenCalledWith(
'Storage provider batch delete partially failed after DB rows were deleted',
{
bucket: 'photos',
failed: [{ key: 'b.txt', message: 'Failed to delete object' }],
}
);
});
it('reports DB-deleted objects as failed when the provider batch delete fails unexpectedly', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
deleteObjects: vi.fn(async () => {
throw new Error('/var/private/storage/photos/a.txt permission denied');
}),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
queryResults = [{ rows: [{ key: 'a.txt' }], rowCount: 1 }];
const result = await svc.deleteObjects({ id: 'local:admin', role: 'project_admin' }, 'photos', [
'a.txt',
]);
expect(result).toEqual({
results: [{ key: 'a.txt', status: 'failed', message: 'Failed to delete object' }],
});
expect(loggerMocks.error).toHaveBeenCalledWith('Storage provider batch delete failed', {
bucket: 'photos',
keys: ['a.txt'],
error: '/var/private/storage/photos/a.txt permission denied',
});
});
it('rejects invalid batch delete keys as storage validation errors before querying', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const { AppError } = await import('@/utils/errors.js');
const svc = StorageService.getInstance();
try {
await svc.deleteObjects({ id: 'local:admin', role: 'project_admin' }, 'photos', ['../bad']);
throw new Error('Expected deleteObjects to throw');
} catch (error) {
expect(error).toBeInstanceOf(AppError);
expect(error).toMatchObject({
message: 'Invalid key. Cannot use ".." or start with "/"',
statusCode: 400,
});
}
expect(calls).toEqual([]);
});
it('returns true for public bucket objects without requiring user context', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
queryResults = [
{ rows: [{ public: true }], rowCount: 1 },
{ rows: [{ '?column?': 1 }], rowCount: 1 },
];
const visible = await svc.getObjectMetadataVisible(undefined, 'photos', 'alice/cat.jpg');
expect(visible).toBeTruthy();
expect(calls).toEqual([
{
sql: 'SELECT public FROM storage.buckets WHERE name = $1',
params: ['photos'],
},
{
sql: 'SELECT * FROM storage.objects WHERE bucket = $1 AND key = $2',
params: ['photos', 'alice/cat.jpg'],
},
]);
});
it('returns false for missing objects in public buckets', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
queryResults = [
{ rows: [{ public: true }], rowCount: 1 },
{ rows: [], rowCount: 0 },
];
const visible = await svc.getObjectMetadataVisible(undefined, 'photos', 'missing.jpg');
expect(visible).toBeNull();
expect(calls).toEqual([
{
sql: 'SELECT public FROM storage.buckets WHERE name = $1',
params: ['photos'],
},
{
sql: 'SELECT * FROM storage.objects WHERE bucket = $1 AND key = $2',
params: ['photos', 'missing.jpg'],
},
]);
});
it('getObject reads public bucket objects without user context', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
getObject: vi.fn(async () => Buffer.from('hello')),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
const uploadedAt = new Date('2026-01-01T00:00:00.000Z');
queryResults = [
{ rows: [{ public: true }], rowCount: 1 },
{
rows: [
{
bucket: 'photos',
key: 'alice/cat.jpg',
size: 42,
mime_type: 'image/jpeg',
uploaded_at: uploadedAt,
etag: 'etag-public',
},
],
rowCount: 1,
},
];
const result = await svc.getObject(undefined, 'photos', 'alice/cat.jpg');
expect(result?.file.toString()).toBe('hello');
expect(result?.metadata).toMatchObject({
bucket: 'photos',
key: 'alice/cat.jpg',
size: 42,
mimeType: 'image/jpeg',
uploadedAt,
});
expect(provider.getObject).toHaveBeenCalledWith('photos', 'alice/cat.jpg');
});
it('getObject reads public bucket objects with a user context without RLS', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
const provider = {
getObject: vi.fn(async () => Buffer.from('hello')),
};
(svc as unknown as { provider: typeof provider }).provider = provider;
const uploadedAt = new Date('2026-01-01T00:00:00.000Z');
queryResults = [
{ rows: [{ public: true }], rowCount: 1 },
{
rows: [
{
bucket: 'photos',
key: 'alice/cat.jpg',
size: 42,
mime_type: 'image/jpeg',
uploaded_at: uploadedAt,
etag: 'etag-public',
},
],
rowCount: 1,
},
];
const result = await svc.getObject(
{ id: 'bob-sub', email: 'bob@example.com', role: 'authenticated' },
'photos',
'alice/cat.jpg'
);
expect(result?.file.toString()).toBe('hello');
expect(calls.map((c) => c.sql)).toEqual([
'SELECT public FROM storage.buckets WHERE name = $1',
'SELECT * FROM storage.objects WHERE bucket = $1 AND key = $2',
]);
expect(provider.getObject).toHaveBeenCalledWith('photos', 'alice/cat.jpg');
});
it('rejects invalid bucket names before touching the database', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
await expect(
svc.getObjectMetadataVisible(
{ id: 'alice', email: 'alice@example.com', role: 'authenticated' },
'no spaces allowed',
'k'
)
).rejects.toThrow(/Invalid bucket name/);
expect(calls).toHaveLength(0);
});
it('rejects directory-traversal keys before touching the database', async () => {
const { StorageService } = await import('@/services/storage/storage.service.js');
const svc = StorageService.getInstance();
await expect(
svc.getObjectMetadataVisible(
{ id: 'alice', email: 'alice@example.com', role: 'authenticated' },
'photos',
'../../etc/passwd'
)
).rejects.toThrow(/Invalid key/);
expect(calls).toHaveLength(0);
});
});