796 lines
28 KiB
TypeScript
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);
|
|
});
|
|
});
|