mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
refactor(llm-parse, mcp-shared, oauth, place-shadow, route-usage, storage, todo, trip-invite): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -316,11 +316,11 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('oauth-sessions')
|
||||
listOAuthSessions() { return { sessions: this.oauth.listAllOAuthSessions() }; }
|
||||
async listOAuthSessions() { return { sessions: await this.oauth.listAllOAuthSessions() }; }
|
||||
|
||||
@Delete('oauth-sessions/:id')
|
||||
async revokeOAuthSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
ok(this.oauth.adminRevokeOAuthSession(id));
|
||||
ok(await this.oauth.adminRevokeOAuthSession(id));
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'admin.oauth_session_revoke', resource: String(id), ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -518,7 +518,7 @@ export async function restoreFromZip(storage: StorageService, zipPath: string):
|
||||
// entirely when reopen failed: with no live DB handle the registry has
|
||||
// nothing to read, and the restore is already reported as "restart
|
||||
// required" below — rehydrating into a stale/guessed config would be worse.
|
||||
storage.reloadConfig();
|
||||
await storage.reloadConfig();
|
||||
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
|
||||
@@ -69,7 +69,7 @@ export class CategoriesMcp {
|
||||
async createCategory({ name, color, icon }: { name: string; color?: string; icon?: string }, ctx: McpContext) {
|
||||
if (await this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
// The palette is instance-wide; the REST route restricts management to admins. Match it.
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
|
||||
const category = await this.categories.create(ctx.userId, name, color, icon);
|
||||
return ok({ category });
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export class CategoriesMcp {
|
||||
})
|
||||
async updateCategory({ categoryId, name, color, icon }: { categoryId: number; name?: string; color?: string; icon?: string }, ctx: McpContext) {
|
||||
if (await this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
|
||||
if (!(await this.categories.getById(categoryId))) return errorResult('Category not found');
|
||||
const category = await this.categories.update(categoryId, name, color, icon);
|
||||
return ok({ category });
|
||||
@@ -105,7 +105,7 @@ export class CategoriesMcp {
|
||||
})
|
||||
async deleteCategory({ categoryId }: { categoryId: number }, ctx: McpContext) {
|
||||
if (await this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
|
||||
if (!(await this.categories.getById(categoryId))) return errorResult('Category not found');
|
||||
await this.categories.remove(categoryId);
|
||||
return ok({ success: true });
|
||||
|
||||
@@ -30,10 +30,10 @@ export class LlmConfigResolver {
|
||||
*/
|
||||
async resolve(userId: number): Promise<ResolvedLlmConfig | null> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING))) return null;
|
||||
return this.readInstanceConfig() ?? (await this.readUserConfig(userId));
|
||||
return (await this.readInstanceConfig()) ?? (await this.readUserConfig(userId));
|
||||
}
|
||||
|
||||
private readInstanceConfig(): ResolvedLlmConfig | null {
|
||||
private async readInstanceConfig(): Promise<ResolvedLlmConfig | null> {
|
||||
const row = this.dbService.get<{ config?: string } | undefined>(
|
||||
'SELECT config FROM addons WHERE id = ?',
|
||||
ADDON_IDS.LLM_PARSING,
|
||||
|
||||
@@ -74,7 +74,7 @@ export class McpToolGuardsService {
|
||||
}
|
||||
|
||||
/** True when the user has the global admin role (mirrors REST `user.role === 'admin'` gates). */
|
||||
isAdminUser(userId: number): boolean {
|
||||
async isAdminUser(userId: number): Promise<boolean> {
|
||||
const userRow = this.db.get<{ role?: string }>('SELECT role FROM users WHERE id = ?', userId);
|
||||
return userRow?.role === 'admin';
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ export class McpTransportService {
|
||||
private readonly registry: McpRegistryService,
|
||||
) {}
|
||||
|
||||
verifyToken(authHeader: string | undefined): VerifyTokenResult | null {
|
||||
async verifyToken(authHeader: string | undefined): Promise<VerifyTokenResult | null> {
|
||||
if (!authHeader) return null;
|
||||
// M8: strictly require "Bearer" scheme (RFC 6750)
|
||||
const spaceIdx = authHeader.indexOf(' ');
|
||||
@@ -136,7 +136,7 @@ export class McpTransportService {
|
||||
|
||||
// OAuth 2.1 access token (trekoa_...)
|
||||
if (token.startsWith('trekoa_')) {
|
||||
const result = this.oauth.getUserByAccessToken(token);
|
||||
const result = await this.oauth.getUserByAccessToken(token);
|
||||
if (!result) return null;
|
||||
// RFC 8707: audience must always match this resource endpoint.
|
||||
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
|
||||
@@ -164,7 +164,7 @@ export class McpTransportService {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenResult = this.verifyToken(req.headers['authorization']);
|
||||
const tokenResult = await this.verifyToken(req.headers['authorization']);
|
||||
if (!tokenResult) {
|
||||
setAuthChallenge(res);
|
||||
res.status(401).json({ error: 'Access token required' });
|
||||
|
||||
@@ -122,7 +122,7 @@ export class OauthApiController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async listClients(@CurrentUser() user: User) {
|
||||
await this.requireMcp403();
|
||||
return { clients: this.oauth.listOAuthClients(user.id) };
|
||||
return { clients: await this.oauth.listOAuthClients(user.id) };
|
||||
}
|
||||
|
||||
@Post('clients')
|
||||
@@ -164,7 +164,7 @@ export class OauthApiController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async listSessions(@CurrentUser() user: User) {
|
||||
await this.requireMcp403();
|
||||
return { sessions: this.oauth.listOAuthSessions(user.id) };
|
||||
return { sessions: await this.oauth.listOAuthSessions(user.id) };
|
||||
}
|
||||
|
||||
@Delete('sessions/:id')
|
||||
|
||||
@@ -67,14 +67,14 @@ export class OauthPublicController {
|
||||
if (pending.clientId !== client_id) return invalidGrant('client_id_mismatch', pending.userId);
|
||||
if (pending.redirectUri !== redirect_uri) return invalidGrant('redirect_uri_mismatch', pending.userId);
|
||||
if (pending.resource && resource && pending.resource !== stripTrailingSlashes(resource)) return invalidGrant('resource_mismatch', pending.userId);
|
||||
if (!this.oauth.authenticateClient(client_id, client_secret)) {
|
||||
if (!(await this.oauth.authenticateClient(client_id, client_secret))) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
return;
|
||||
}
|
||||
if (!this.oauth.verifyPKCE(code_verifier, pending.codeChallenge)) return invalidGrant('pkce_failed', pending.userId);
|
||||
const tokens = this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||
const tokens = await this.oauth.issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||
await this.audit.writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip });
|
||||
res.json(tokens);
|
||||
return;
|
||||
@@ -100,7 +100,7 @@ export class OauthPublicController {
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' });
|
||||
return;
|
||||
}
|
||||
const client = this.oauth.authenticateClient(client_id, client_secret);
|
||||
const client = await this.oauth.authenticateClient(client_id, client_secret);
|
||||
if (!client) {
|
||||
logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip });
|
||||
@@ -126,7 +126,7 @@ export class OauthPublicController {
|
||||
grantedScopes = allowedScopes;
|
||||
}
|
||||
const audience = resource ? stripTrailingSlashes(resource) : `${stripTrailingSlashes(this.oauth.mcpSafeUrl())}/mcp`;
|
||||
const tokens = this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
|
||||
const tokens = await this.oauth.issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience);
|
||||
await this.audit.writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip });
|
||||
res.json(tokens);
|
||||
return;
|
||||
@@ -143,7 +143,7 @@ export class OauthPublicController {
|
||||
res.status(401).json({ error: 'invalid_token' });
|
||||
return;
|
||||
}
|
||||
const info = this.oauth.getUserByAccessToken(auth.slice(7));
|
||||
const info = await this.oauth.getUserByAccessToken(auth.slice(7));
|
||||
if (!info) {
|
||||
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
|
||||
res.status(401).json({ error: 'invalid_token' });
|
||||
@@ -166,7 +166,7 @@ export class OauthPublicController {
|
||||
res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' });
|
||||
return;
|
||||
}
|
||||
if (!this.oauth.authenticateClient(client_id, client_secret)) {
|
||||
if (!(await this.oauth.authenticateClient(client_id, client_secret))) {
|
||||
logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`);
|
||||
await this.audit.writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip });
|
||||
res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
|
||||
|
||||
@@ -44,7 +44,7 @@ function assertValidRedirectUris(uris: string[]): void {
|
||||
// Row → SDK client info shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rowToInfo(row: NonNullable<ReturnType<OauthService['getSdkClient']>>): OAuthClientInformationFull {
|
||||
function rowToInfo(row: NonNullable<Awaited<ReturnType<OauthService['getSdkClient']>>>): OAuthClientInformationFull {
|
||||
return {
|
||||
client_id: row.client_id,
|
||||
client_name: row.name,
|
||||
@@ -65,7 +65,7 @@ export class TrekClientsStore implements OAuthRegisteredClientsStore {
|
||||
constructor(private readonly oauth: OauthService) {}
|
||||
|
||||
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
|
||||
const row = this.oauth.getSdkClient(clientId);
|
||||
const row = await this.oauth.getSdkClient(clientId);
|
||||
return row ? rowToInfo(row) : undefined;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export class TrekOAuthProvider implements OAuthServerProvider {
|
||||
if (!codeVerifier || !this.oauth.verifyPKCE(codeVerifier, pending.codeChallenge))
|
||||
throw new Error('Authorization grant is invalid.');
|
||||
|
||||
const tokens = this.oauth.issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||
const tokens = await this.oauth.issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
|
||||
await this.audit.writeAudit({
|
||||
userId: pending.userId,
|
||||
action: 'oauth.token.issue',
|
||||
@@ -200,7 +200,7 @@ export class TrekOAuthProvider implements OAuthServerProvider {
|
||||
}
|
||||
|
||||
async verifyAccessToken(token: string): Promise<AuthInfo> {
|
||||
const info = this.oauth.getUserByAccessToken(token);
|
||||
const info = await this.oauth.getUserByAccessToken(token);
|
||||
if (!info) throw new Error('Invalid or expired token');
|
||||
return {
|
||||
token,
|
||||
|
||||
@@ -92,7 +92,7 @@ export class OauthService {
|
||||
// Client management (self-service, gated by MCP addon)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
listOAuthClients(userId: number): Record<string, unknown>[] {
|
||||
async listOAuthClients(userId: number): Promise<Record<string, unknown>[]> {
|
||||
const rows = this.db.all<OAuthClientRow>(
|
||||
'SELECT id, user_id, name, client_id, redirect_uris, allowed_scopes, created_at, is_public, created_via, allows_client_credentials FROM oauth_clients WHERE user_id = ? ORDER BY created_at DESC',
|
||||
userId,
|
||||
@@ -246,7 +246,7 @@ export class OauthService {
|
||||
// Consent management
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getConsent(clientId: string, userId: number): string[] | null {
|
||||
async getConsent(clientId: string, userId: number): Promise<string[] | null> {
|
||||
const row = this.db.get<{ scopes: string }>(
|
||||
'SELECT scopes FROM oauth_consents WHERE client_id = ? AND user_id = ?', clientId, userId,
|
||||
);
|
||||
@@ -255,7 +255,7 @@ export class OauthService {
|
||||
|
||||
async saveConsent(clientId: string, userId: number, scopes: string[], ip?: string | null): Promise<void> {
|
||||
// Union existing consent with newly approved scopes (M5: never narrow stored consent)
|
||||
const existing = this.getConsent(clientId, userId) ?? [];
|
||||
const existing = (await this.getConsent(clientId, userId)) ?? [];
|
||||
const merged = Array.from(new Set([...existing, ...scopes]));
|
||||
this.db.run(
|
||||
'INSERT OR REPLACE INTO oauth_consents (client_id, user_id, scopes, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
|
||||
@@ -272,19 +272,19 @@ export class OauthService {
|
||||
// Token issuance
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
issueTokens(
|
||||
async issueTokens(
|
||||
clientId: string,
|
||||
userId: number,
|
||||
scopes: string[],
|
||||
parentTokenId: number | null = null,
|
||||
audience: string | null = null,
|
||||
): {
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
} {
|
||||
}> {
|
||||
const rawAccess = generateAccessToken();
|
||||
const rawRefresh = generateRefreshToken();
|
||||
const accessHash = hashToken(rawAccess);
|
||||
@@ -316,17 +316,17 @@ export class OauthService {
|
||||
* can never be presented as a valid refresh token (same precedent as public
|
||||
* client secret hashes stored in client_secret_hash).
|
||||
*/
|
||||
issueClientCredentialsToken(
|
||||
async issueClientCredentialsToken(
|
||||
clientId: string,
|
||||
userId: number,
|
||||
scopes: string[],
|
||||
audience: string,
|
||||
): {
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
token_type: 'Bearer';
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
} {
|
||||
}> {
|
||||
const rawAccess = generateAccessToken();
|
||||
const accessHash = hashToken(rawAccess);
|
||||
const placeholderHash = randomBytes(32).toString('hex');
|
||||
@@ -354,21 +354,21 @@ export class OauthService {
|
||||
|
||||
/** SDK clients-store read: the exact row shape the MCP SDK adapter
|
||||
* (oauth-sdk.provider.ts) maps to OAuthClientInformationFull. */
|
||||
getSdkClient(clientId: string): {
|
||||
async getSdkClient(clientId: string): Promise<{
|
||||
client_id: string;
|
||||
name: string;
|
||||
redirect_uris: string;
|
||||
allowed_scopes: string;
|
||||
is_public: number;
|
||||
created_via: string;
|
||||
} | undefined {
|
||||
} | undefined> {
|
||||
return this.db.get(
|
||||
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?',
|
||||
clientId,
|
||||
);
|
||||
}
|
||||
|
||||
getUserByAccessToken(rawToken: string): OAuthTokenInfo | null {
|
||||
async getUserByAccessToken(rawToken: string): Promise<OAuthTokenInfo | null> {
|
||||
const hash = hashToken(rawToken);
|
||||
const row = this.db.get<OAuthTokenRow & { username: string; email: string; role: string }>(`
|
||||
SELECT ot.scopes, ot.audience, ot.revoked_at, ot.access_token_expires_at,
|
||||
@@ -395,7 +395,7 @@ export class OauthService {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Walk parent_token_id upward to find the root token id of this rotation chain. */
|
||||
private findChainRoot(tokenId: number): number {
|
||||
private async findChainRoot(tokenId: number): Promise<number> {
|
||||
let current = tokenId;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const row = this.db.get<{ id: number; parent_token_id: number | null }>('SELECT id, parent_token_id FROM oauth_tokens WHERE id = ?', current);
|
||||
@@ -406,7 +406,7 @@ export class OauthService {
|
||||
}
|
||||
|
||||
/** Revoke all tokens in the rotation chain rooted at rootId. Returns affected ids. */
|
||||
private revokeChain(rootId: number): number[] {
|
||||
private async revokeChain(rootId: number): Promise<number[]> {
|
||||
const rows = this.db.all<{ id: number }>(`
|
||||
WITH RECURSIVE chain(id) AS (
|
||||
SELECT id FROM oauth_tokens WHERE id = ?
|
||||
@@ -435,7 +435,7 @@ export class OauthService {
|
||||
* explicit revoke leaves no live child, and a chain revoked after a real replay
|
||||
* has every child revoked with it, so neither can slip through here.
|
||||
*/
|
||||
private isConcurrentRotation(row: OAuthTokenRow): boolean {
|
||||
private async isConcurrentRotation(row: OAuthTokenRow): Promise<boolean> {
|
||||
const revokedAt = parseSqliteUtc(row.revoked_at);
|
||||
if (!revokedAt) return false;
|
||||
if (Date.now() - revokedAt.getTime() > REFRESH_ROTATION_GRACE_MS) return false;
|
||||
@@ -451,7 +451,7 @@ export class OauthService {
|
||||
clientId: string,
|
||||
clientSecret: string | undefined,
|
||||
ip?: string | null,
|
||||
): Promise<{ error?: string; status?: number; tokens?: ReturnType<OauthService['issueTokens']> }> {
|
||||
): Promise<{ error?: string; status?: number; tokens?: Awaited<ReturnType<OauthService['issueTokens']>> }> {
|
||||
const client = this.db.get<OAuthClientRow>('SELECT client_id, client_secret_hash, is_public FROM oauth_clients WHERE client_id = ?', clientId);
|
||||
if (!client) return { error: 'invalid_client', status: 401 };
|
||||
if (!client.is_public) {
|
||||
@@ -476,8 +476,8 @@ export class OauthService {
|
||||
// not theft (#1007): they share one token, both post it, and the loser used
|
||||
// to take the whole chain down with it. Issue a sibling pair off the same
|
||||
// parent so each client walks away with its own token.
|
||||
if (this.isConcurrentRotation(row)) {
|
||||
const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
if (await this.isConcurrentRotation(row)) {
|
||||
const tokens = await this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
await this.audit.writeAudit({
|
||||
userId: row.user_id,
|
||||
action: 'oauth.token.refresh',
|
||||
@@ -488,8 +488,8 @@ export class OauthService {
|
||||
}
|
||||
|
||||
// A revoked refresh token was replayed — assume token theft. Cascade-revoke the chain.
|
||||
const rootId = this.findChainRoot(row.id);
|
||||
this.revokeChain(rootId);
|
||||
const rootId = await this.findChainRoot(row.id);
|
||||
await this.revokeChain(rootId);
|
||||
|
||||
revokeUserSessionsForClient(row.user_id, clientId);
|
||||
|
||||
@@ -514,7 +514,7 @@ export class OauthService {
|
||||
// MCP connections (#1475).
|
||||
this.db.run('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?', row.id);
|
||||
|
||||
const tokens = this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
const tokens = await this.issueTokens(clientId, row.user_id, JSON.parse(row.scopes), row.id, row.audience ?? null);
|
||||
await this.audit.writeAudit({ userId: row.user_id, action: 'oauth.token.refresh', details: { client_id: clientId }, ip });
|
||||
|
||||
return { tokens };
|
||||
@@ -550,7 +550,7 @@ export class OauthService {
|
||||
// Active session listing (for user settings page)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
listOAuthSessions(userId: number): Record<string, unknown>[] {
|
||||
async listOAuthSessions(userId: number): Promise<Record<string, unknown>[]> {
|
||||
const rows = this.db.all<Record<string, unknown>>(`
|
||||
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.scopes,
|
||||
ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
|
||||
@@ -659,7 +659,7 @@ export class OauthService {
|
||||
return { valid: true, loginRequired: true };
|
||||
}
|
||||
|
||||
const existingConsent = this.getConsent(params.client_id, userId);
|
||||
const existingConsent = await this.getConsent(params.client_id, userId);
|
||||
const consentRequired = !existingConsent || !this.isConsentSufficient(existingConsent, grantedScopes);
|
||||
|
||||
return {
|
||||
@@ -692,7 +692,7 @@ export class OauthService {
|
||||
// Client authentication (for token endpoint)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
authenticateClient(clientId: string, clientSecret: string | undefined): OAuthClientRow | null {
|
||||
async authenticateClient(clientId: string, clientSecret: string | undefined): Promise<OAuthClientRow | null> {
|
||||
const client = this.db.get<OAuthClientRow>('SELECT * FROM oauth_clients WHERE client_id = ?', clientId);
|
||||
if (!client) return null;
|
||||
if (client.is_public) {
|
||||
@@ -711,7 +711,7 @@ export class OauthService {
|
||||
// panel route is under /api/admin. The route keeps its path and its guard.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
listAllOAuthSessions() {
|
||||
async listAllOAuthSessions() {
|
||||
const rows = this.db.all<Record<string, unknown> & { scopes: string }>(`
|
||||
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
|
||||
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
|
||||
@@ -734,7 +734,7 @@ export class OauthService {
|
||||
});
|
||||
}
|
||||
|
||||
adminRevokeOAuthSession(id: string) {
|
||||
async adminRevokeOAuthSession(id: string) {
|
||||
const row = this.db.get<{ id: number; user_id: number; client_id: string }>(
|
||||
'SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?', id,
|
||||
);
|
||||
|
||||
@@ -481,7 +481,7 @@ export class PackingMcp {
|
||||
if (!this.packing.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// Templates are global; the REST route restricts saving to admins. Match it.
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
|
||||
const template = this.packing.saveAsTemplate(tripId, ctx.userId, templateName);
|
||||
if (!template) return errorResult('Nothing to save — the packing list is empty.');
|
||||
return ok({ template });
|
||||
@@ -500,7 +500,7 @@ export class PackingMcp {
|
||||
async deletePackingTemplate({ templateId }: { templateId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
// Templates are global; the REST route restricts management to admins. Match it.
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
|
||||
const result = this.packing.deletePackingTemplate(String(templateId));
|
||||
if ('error' in result) return errorResult(result.error);
|
||||
return ok({ success: true, name: result.name });
|
||||
|
||||
@@ -29,26 +29,26 @@ export class PlaceShadowController {
|
||||
*/
|
||||
@Post('pick')
|
||||
@HttpCode(200)
|
||||
pick(@Body() body: PlaceShadowPickDto): PlaceShadowPickResult {
|
||||
return { recorded: this.shadow.record(body) };
|
||||
async pick(@Body() body: PlaceShadowPickDto): Promise<PlaceShadowPickResult> {
|
||||
return { recorded: await this.shadow.record(body) };
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
summary(): PlaceShadowSummaryResult {
|
||||
return this.shadow.summary();
|
||||
async summary(): Promise<PlaceShadowSummaryResult> {
|
||||
return await this.shadow.summary();
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
export(@Query('after') after?: string): PlaceShadowExportResult {
|
||||
async export(@Query('after') after?: string): Promise<PlaceShadowExportResult> {
|
||||
const parsed = Number(after);
|
||||
return this.shadow.export(Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined);
|
||||
return await this.shadow.export(Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
clear(): { removed: number } {
|
||||
return { removed: this.shadow.clear() };
|
||||
async clear(): Promise<{ removed: number }> {
|
||||
return { removed: await this.shadow.clear() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,17 @@ export class PlaceShadowRetentionJob implements OnApplicationBootstrap {
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
// `async` only so the sweep's call-graph gate sees a non-sync DB-reaching
|
||||
// frame here: nothing in the body awaits, and Nest awaits the returned
|
||||
// promise before the next bootstrap hook, exactly as it did the `void`.
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
this.registrar.register('place-shadow-retention', '40 3 * * *', () => this.tick());
|
||||
}
|
||||
|
||||
tick(): void {
|
||||
async tick(): Promise<void> {
|
||||
try {
|
||||
const removed = this.shadow.purgeExpired();
|
||||
const removed = await this.shadow.purgeExpired();
|
||||
if (removed > 0) {
|
||||
logInfo(`Place shadow retention: removed ${removed} expired row(s)`);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function toRow(r: DbRow): PlaceShadowRow {
|
||||
export class PlaceShadowService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
enabled(): boolean {
|
||||
async enabled(): Promise<boolean> {
|
||||
const row = this.db.get<{ value: string }>(
|
||||
"SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'",
|
||||
);
|
||||
@@ -87,8 +87,8 @@ export class PlaceShadowService {
|
||||
* somebody adds a place would be noise about a feature that is off on
|
||||
* purpose.
|
||||
*/
|
||||
record(pick: PlaceShadowPickRequest): boolean {
|
||||
if (!this.enabled()) return false;
|
||||
async record(pick: PlaceShadowPickRequest): Promise<boolean> {
|
||||
if (!(await this.enabled())) return false;
|
||||
// A rank outside the returned list means the client and the server disagree
|
||||
// about what was on screen. Storing it would poison the very number the
|
||||
// corpus exists to produce, so the row is dropped instead.
|
||||
@@ -119,7 +119,7 @@ export class PlaceShadowService {
|
||||
* previous page; paging by id rather than by offset keeps the pages stable
|
||||
* while new rows arrive underneath.
|
||||
*/
|
||||
export(after?: number, limit = EXPORT_PAGE_SIZE): PlaceShadowExportResult {
|
||||
async export(after?: number, limit = EXPORT_PAGE_SIZE): Promise<PlaceShadowExportResult> {
|
||||
const size = Math.min(Math.max(1, limit), EXPORT_PAGE_SIZE);
|
||||
const rows = this.db.all<DbRow>(
|
||||
`SELECT * FROM place_shadow_picks
|
||||
@@ -138,8 +138,8 @@ export class PlaceShadowService {
|
||||
};
|
||||
}
|
||||
|
||||
summary(): PlaceShadowSummaryResult {
|
||||
const enabled = this.enabled();
|
||||
async summary(): Promise<PlaceShadowSummaryResult> {
|
||||
const enabled = await this.enabled();
|
||||
const totals = this.db.get<{ total: number; oldest: string | null; newest: string | null }>(
|
||||
'SELECT COUNT(*) AS total, MIN(created_at) AS oldest, MAX(created_at) AS newest FROM place_shadow_picks',
|
||||
);
|
||||
@@ -181,12 +181,12 @@ export class PlaceShadowService {
|
||||
}
|
||||
|
||||
/** Admin wipe. Returns how many rows went. */
|
||||
clear(): number {
|
||||
async clear(): Promise<number> {
|
||||
return this.db.run('DELETE FROM place_shadow_picks').changes;
|
||||
}
|
||||
|
||||
/** Nightly retention. Returns how many rows went. */
|
||||
purgeExpired(retentionDays = RETENTION_DAYS): number {
|
||||
async purgeExpired(retentionDays = RETENTION_DAYS): Promise<number> {
|
||||
return this.db.run(
|
||||
`DELETE FROM place_shadow_picks
|
||||
WHERE created_at < datetime('now', ?)`,
|
||||
|
||||
@@ -25,19 +25,19 @@ export class RouteUsageController {
|
||||
*/
|
||||
@Post('report')
|
||||
@HttpCode(200)
|
||||
report(@Body() body: RouteUsageReportDto): RouteUsageReportResult {
|
||||
return { recorded: this.usage.record(body) };
|
||||
async report(@Body() body: RouteUsageReportDto): Promise<RouteUsageReportResult> {
|
||||
return { recorded: await this.usage.record(body) };
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
summary(): RouteUsageSummaryResult {
|
||||
return this.usage.summary();
|
||||
async summary(): Promise<RouteUsageSummaryResult> {
|
||||
return await this.usage.summary();
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
clear(): { removed: number } {
|
||||
return { removed: this.usage.clear() };
|
||||
async clear(): Promise<{ removed: number }> {
|
||||
return { removed: await this.usage.clear() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,17 @@ export class RouteUsageRetentionJob implements OnApplicationBootstrap {
|
||||
private readonly registrar: CronRegistrarService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
// `async` only so the sweep's call-graph gate sees a non-sync DB-reaching
|
||||
// frame here: nothing in the body awaits, and Nest awaits the returned
|
||||
// promise before the next bootstrap hook, exactly as it did the `void`.
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
this.registrar.register('route-usage-retention', '45 3 * * *', () => this.tick());
|
||||
}
|
||||
|
||||
tick(): void {
|
||||
async tick(): Promise<void> {
|
||||
try {
|
||||
const removed = this.usage.purgeExpired();
|
||||
const removed = await this.usage.purgeExpired();
|
||||
if (removed > 0) {
|
||||
logInfo(`Route usage retention: removed ${removed} expired row(s)`);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
RouteUsageSurface,
|
||||
} from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
|
||||
/** Days a counted day survives. Aggregates are tiny, so this is a year and a bit. */
|
||||
export const RETENTION_DAYS = 400;
|
||||
@@ -40,9 +41,12 @@ interface DbRow {
|
||||
*/
|
||||
@Injectable()
|
||||
export class RouteUsageService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
enabled(): boolean {
|
||||
async enabled(): Promise<boolean> {
|
||||
const row = this.db.get<{ value: string }>(
|
||||
"SELECT value FROM app_settings WHERE key = 'route_usage_enabled'",
|
||||
);
|
||||
@@ -56,11 +60,11 @@ export class RouteUsageService {
|
||||
* switched-off instance answers 200 rather than an error the client would log on
|
||||
* every flush.
|
||||
*/
|
||||
record(report: RouteUsageReportRequest): boolean {
|
||||
if (!this.enabled()) return false;
|
||||
async record(report: RouteUsageReportRequest): Promise<boolean> {
|
||||
if (!(await this.enabled())) return false;
|
||||
// One transaction for the batch: a flush is a handful of rows, and a partial
|
||||
// one would leave a day counted twice on the client's next retry.
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
for (const entry of report.entries) {
|
||||
this.db.run(
|
||||
`INSERT INTO route_usage_daily (day, profile, surface, self_hosted, requests, waypoints, km, failed)
|
||||
@@ -83,7 +87,7 @@ export class RouteUsageService {
|
||||
return true;
|
||||
}
|
||||
|
||||
rows(): RouteUsageDayRow[] {
|
||||
async rows(): Promise<RouteUsageDayRow[]> {
|
||||
const rows = this.db.all<DbRow>(
|
||||
'SELECT * FROM route_usage_daily ORDER BY day DESC, profile, surface',
|
||||
);
|
||||
@@ -99,9 +103,9 @@ export class RouteUsageService {
|
||||
}));
|
||||
}
|
||||
|
||||
summary(): RouteUsageSummaryResult {
|
||||
const rows = this.rows();
|
||||
const enabled = this.enabled();
|
||||
async summary(): Promise<RouteUsageSummaryResult> {
|
||||
const rows = await this.rows();
|
||||
const enabled = await this.enabled();
|
||||
const empty: RouteUsageSummaryResult = {
|
||||
enabled,
|
||||
retentionDays: RETENTION_DAYS,
|
||||
@@ -169,7 +173,7 @@ export class RouteUsageService {
|
||||
}
|
||||
|
||||
/** Removes days past the retention window. Returns how many rows went. */
|
||||
purgeExpired(): number {
|
||||
async purgeExpired(): Promise<number> {
|
||||
const result = this.db.run(
|
||||
`DELETE FROM route_usage_daily WHERE day < date('now', ?)`,
|
||||
`-${RETENTION_DAYS} days`,
|
||||
@@ -178,7 +182,7 @@ export class RouteUsageService {
|
||||
}
|
||||
|
||||
/** Wipes every counter. The admin's own "start over". */
|
||||
clear(): number {
|
||||
async clear(): Promise<number> {
|
||||
const result = this.db.run('DELETE FROM route_usage_daily');
|
||||
return result.changes ?? 0;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export class SchoolHolidaysMcp {
|
||||
constructor(private readonly holidays: SchoolHolidaysService, private readonly guards: McpToolGuardsService) {}
|
||||
|
||||
private async adminWrite(ctx: McpContext, write: () => unknown) {
|
||||
if (!this.guards.isAdminUser(ctx.userId)) return adminRequired();
|
||||
if (!(await this.guards.isAdminUser(ctx.userId))) return adminRequired();
|
||||
try { return ok(await write()); }
|
||||
catch (error) {
|
||||
if (error instanceof HttpException || error instanceof z.ZodError) return errorResult(error.message);
|
||||
|
||||
@@ -37,14 +37,14 @@ export class StorageAdminController {
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
get() {
|
||||
return this.service.state();
|
||||
async get() {
|
||||
return await this.service.state();
|
||||
}
|
||||
|
||||
@Put()
|
||||
async update(@CurrentUser() user: User, @Body() body: StorageConfigDto, @Req() req: Request) {
|
||||
try {
|
||||
this.service.applyConfig(body);
|
||||
await this.service.applyConfig(body);
|
||||
} catch (err) {
|
||||
// The conflict branch must come before the blanket 400: a
|
||||
// StorageConflictError IS an Error, so the generic catch-all below
|
||||
@@ -127,7 +127,7 @@ export class StorageAdminController {
|
||||
async migrationStart(@CurrentUser() user: User, @Body() body: StorageMigrationRequestDto, @Req() req: Request): Promise<{ started: true }> {
|
||||
const { category, to } = body;
|
||||
try {
|
||||
this.service.startMigration(category, to);
|
||||
await this.service.startMigration(category, to);
|
||||
} catch (err) {
|
||||
if (err instanceof MigrationRequestError) throw new HttpException({ error: err.message }, 400);
|
||||
if (err instanceof MigrationTargetError) throw new HttpException({ error: err.message }, 404);
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { StorageAdminState, StorageBackend, StorageConfigPut, StorageTestResponse, StorageUsage } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import {
|
||||
BACKENDS_KEY,
|
||||
CATEGORIES_KEY,
|
||||
@@ -37,10 +38,11 @@ export class StorageAdminService {
|
||||
private readonly storage: StorageService,
|
||||
private readonly jobs: StorageJobsService,
|
||||
private readonly stats: StorageStatsService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
/** The effective world — secrets masked, categories cross-referenced per backend. */
|
||||
state(): StorageAdminState {
|
||||
async state(): Promise<StorageAdminState> {
|
||||
const snapshot = this.registry.snapshot();
|
||||
const assignments = Object.entries(snapshot.categories) as Array<
|
||||
[keyof typeof snapshot.categories, { backend: string; source: 'default' | 'settings' }]
|
||||
@@ -56,10 +58,10 @@ export class StorageAdminService {
|
||||
categories: snapshot.categories,
|
||||
health: { replicaFailures: this.storage.health().replicaFailures.map((f) => ({ ...f })) },
|
||||
seedFilePresent: fs.existsSync(SEED_CONFIG_PATH),
|
||||
usage: this.stats.readUsage(),
|
||||
usage: await this.stats.readUsage(),
|
||||
backfills: this.jobs.statuses(),
|
||||
migrations: this.jobs.migrationStatuses(),
|
||||
version: this.registry.currentConfigVersion(),
|
||||
version: await this.registry.currentConfigVersion(),
|
||||
configError: this.registry.lastLoadError(),
|
||||
};
|
||||
}
|
||||
@@ -82,8 +84,8 @@ export class StorageAdminService {
|
||||
* job registry; throws MigrationRequestError (400) / MigrationTargetError (404) /
|
||||
* BackfillBusyError (409).
|
||||
*/
|
||||
startMigration(category: StorageCategory, to: string): void {
|
||||
this.jobs.startMigration(category, to);
|
||||
async startMigration(category: StorageCategory, to: string): Promise<void> {
|
||||
await this.jobs.startMigration(category, to);
|
||||
}
|
||||
|
||||
/** True when an active migration was cancelled; false when there was nothing to cancel. */
|
||||
@@ -92,8 +94,8 @@ export class StorageAdminService {
|
||||
}
|
||||
|
||||
/** Runs and persists a fresh usage scan. Throws StatsBusyError (409) if one is already running. */
|
||||
refreshStats(): Promise<StorageUsage> {
|
||||
return this.stats.scan();
|
||||
async refreshStats(): Promise<StorageUsage> {
|
||||
return await this.stats.scan();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,19 +106,19 @@ export class StorageAdminService {
|
||||
* config that moved on since the form was loaded (e.g. a category
|
||||
* migration's flip, which bumps the same counter).
|
||||
*/
|
||||
applyConfig(config: StorageConfigPut): void {
|
||||
const currentVersion = this.registry.currentConfigVersion();
|
||||
async applyConfig(config: StorageConfigPut): Promise<void> {
|
||||
const currentVersion = await this.registry.currentConfigVersion();
|
||||
if (config.version !== currentVersion) {
|
||||
throw new StorageConflictError(currentVersion, config.version);
|
||||
}
|
||||
const unmasked = unmaskStorageConfig(config, this.storedBackendsRow());
|
||||
const unmasked = unmaskStorageConfig(config, await this.storedBackendsRow());
|
||||
// unmask only resolves the secret fields it knows about; a mask sentinel
|
||||
// submitted in a non-secret field would otherwise pass through untouched
|
||||
// and get persisted verbatim as garbage-in.
|
||||
assertNoMaskSentinels(unmasked);
|
||||
this.registry.preview({ backends: unmasked.backends, categories: unmasked.categories });
|
||||
const encrypted = encryptStorageSecrets(unmasked);
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
const upsert = this.db.prepare(
|
||||
'INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
);
|
||||
@@ -124,7 +126,7 @@ export class StorageAdminService {
|
||||
upsert.run(CATEGORIES_KEY, JSON.stringify(encrypted.categories));
|
||||
upsert.run(VERSION_KEY, String(currentVersion + 1));
|
||||
});
|
||||
this.registry.reload();
|
||||
await this.registry.reload();
|
||||
// Any running job whose backend the reloaded config no longer has ends
|
||||
// cancelled rather than running invisibly against a stale driver ref
|
||||
// (polish item 3) — see StorageJobsService.cancelJobsForMissingBackends.
|
||||
@@ -139,7 +141,7 @@ export class StorageAdminService {
|
||||
async testBackend(candidate: StorageBackend): Promise<StorageTestResponse> {
|
||||
const { backends } = unmaskStorageConfig(
|
||||
{ backends: [candidate], categories: {} },
|
||||
this.storedBackendsRow(),
|
||||
await this.storedBackendsRow(),
|
||||
);
|
||||
const backend = backends[0]!;
|
||||
const targets = this.probeTargetsFor(backend).map(decryptBackendSecrets) as Array<
|
||||
@@ -180,7 +182,7 @@ export class StorageAdminService {
|
||||
}
|
||||
|
||||
/** The raw stored backends row — the unmask source (tolerates absent/garbage rows). */
|
||||
private storedBackendsRow(): unknown {
|
||||
private async storedBackendsRow(): Promise<unknown> {
|
||||
const row = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', BACKENDS_KEY);
|
||||
if (!row?.value) return [];
|
||||
try {
|
||||
|
||||
@@ -147,7 +147,7 @@ export class StorageJobsService {
|
||||
* Throws MigrationRequestError (400) / MigrationTargetError (404) /
|
||||
* BackfillBusyError (409, shared with backfills — one storage job at a time).
|
||||
*/
|
||||
startMigration(category: StorageCategory, to: string): void {
|
||||
async startMigration(category: StorageCategory, to: string): Promise<void> {
|
||||
if (!STORAGE_CATEGORIES.includes(category)) {
|
||||
throw new MigrationRequestError(`'${category}' is not a configurable category`);
|
||||
}
|
||||
@@ -318,13 +318,16 @@ export class StorageJobsService {
|
||||
// last object's await (or an empty category, which never enters the loop
|
||||
// at all) would otherwise reach the flip unchecked. Everything from here
|
||||
// to assignCategory() below is one synchronous stretch, so this is the
|
||||
// last point a cancel can still be honored before the flip.
|
||||
// last point a cancel can still be honored before the flip. (The flip
|
||||
// itself now awaits — assignCategory writes through UnitOfWork — but
|
||||
// nothing between this check and that call yields, so the window is the
|
||||
// same one it always was.)
|
||||
if (job.cancelled) {
|
||||
job.status = { ...s(), status: 'cancelled', finishedAt: Date.now() };
|
||||
return;
|
||||
}
|
||||
// Phase 2: flip — the job, not the save, owns this.
|
||||
this.registry.assignCategory(s().category, s().to);
|
||||
await this.registry.assignCategory(s().category, s().to);
|
||||
// Phase 3: delta sweep + reclaimable tally. Cancellation is ignored here
|
||||
// (bounded). Reclaimable stays SOURCE-keyed — it counts what's left to
|
||||
// reclaim on the old backend, not where it landed on the new one.
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { STORAGE_BACKEND_TYPES, storageConfigSchema } from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { LocalDriver } from './drivers/local.driver';
|
||||
@@ -133,16 +134,17 @@ export class StorageRegistryService implements OnModuleInit {
|
||||
private readonly db: DatabaseService,
|
||||
private readonly env: RuntimeEnvService,
|
||||
private readonly events: StorageEventsService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.seedFromFileOnce();
|
||||
this.load(true);
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.seedFromFileOnce();
|
||||
await this.load(true);
|
||||
}
|
||||
|
||||
/** Re-read settings, validate, atomically swap. In-flight ops keep their resolved instances. */
|
||||
reload(): void {
|
||||
this.load(false);
|
||||
async reload(): Promise<void> {
|
||||
await this.load(false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,8 +202,8 @@ export class StorageRegistryService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/** Current optimistic-concurrency counter — 0 when never bumped (fresh install). */
|
||||
currentConfigVersion(): number {
|
||||
return readConfigVersion(this.db);
|
||||
async currentConfigVersion(): Promise<number> {
|
||||
return await readConfigVersion(this.db);
|
||||
}
|
||||
|
||||
/** Non-null when the last load() fell back (last-good config or built-in defaults) — null once a load succeeds. */
|
||||
@@ -218,7 +220,7 @@ export class StorageRegistryService implements OnModuleInit {
|
||||
* PUT built against the pre-flip version now conflicts instead of silently
|
||||
* overwriting this assignment.
|
||||
*/
|
||||
assignCategory(category: StorageCategory, backend: string): void {
|
||||
async assignCategory(category: StorageCategory, backend: string): Promise<void> {
|
||||
// Belt-and-braces alongside the migration job's own pre-flip cancel guard:
|
||||
// never persist a category pointing at a backend that doesn't exist in
|
||||
// the current snapshot (e.g. a config save removed it mid-migration).
|
||||
@@ -240,17 +242,17 @@ export class StorageRegistryService implements OnModuleInit {
|
||||
`cannot assign '${category}' to '${backend}' — '${owner}' is a mirror replica of '${holder.name}', and that mirror's sync sweep would delete the category's objects`,
|
||||
);
|
||||
}
|
||||
const stored = new Map(parseCategoryMap(this.readSettings().categories));
|
||||
const stored = new Map(parseCategoryMap((await this.readSettings()).categories));
|
||||
stored.set(category, backend);
|
||||
const next = Object.fromEntries(stored);
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
const upsert = this.db.prepare(
|
||||
'INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
);
|
||||
upsert.run(CATEGORIES_KEY, JSON.stringify(next));
|
||||
upsert.run(VERSION_KEY, String(readConfigVersion(this.db) + 1));
|
||||
upsert.run(VERSION_KEY, String((await readConfigVersion(this.db)) + 1));
|
||||
});
|
||||
this.reload();
|
||||
await this.reload();
|
||||
}
|
||||
|
||||
recordReplicaFailure(failure: ReplicaFailure): void {
|
||||
@@ -275,7 +277,7 @@ export class StorageRegistryService implements OnModuleInit {
|
||||
* Recovery: stop the server, DELETE FROM app_settings WHERE key LIKE
|
||||
* 'storage.%', restart (documented in the README with slice 3).
|
||||
*/
|
||||
private seedFromFileOnce(): void {
|
||||
private async seedFromFileOnce(): Promise<void> {
|
||||
const rowCount = this.db.get<{ n: number }>(
|
||||
'SELECT COUNT(*) AS n FROM app_settings WHERE key IN (?, ?)',
|
||||
BACKENDS_KEY,
|
||||
@@ -313,7 +315,7 @@ export class StorageRegistryService implements OnModuleInit {
|
||||
return fail(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
const encrypted = encryptStorageSecrets(config);
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
const upsert = this.db.prepare(
|
||||
'INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
||||
);
|
||||
@@ -330,9 +332,9 @@ export class StorageRegistryService implements OnModuleInit {
|
||||
* state is kept (at boot, when there is no previous state, the built-in
|
||||
* defaults — which cannot be misconfigured — are loaded instead).
|
||||
*/
|
||||
private load(boot: boolean): void {
|
||||
private async load(boot: boolean): Promise<void> {
|
||||
try {
|
||||
this.state = this.build(this.readSettings(), boot);
|
||||
this.state = this.build(await this.readSettings(), boot);
|
||||
this.loadFailure = null;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -345,7 +347,7 @@ export class StorageRegistryService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private readSettings(): { backends: unknown; categories: unknown } {
|
||||
private async readSettings(): Promise<{ backends: unknown; categories: unknown }> {
|
||||
const read = (key: string): unknown => {
|
||||
const row = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', key);
|
||||
if (!row?.value) return undefined;
|
||||
@@ -480,7 +482,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
/** Raw read of the version counter row — 0 for an absent/garbage row (fresh install, or a hand-edited DB). */
|
||||
function readConfigVersion(db: DatabaseService): number {
|
||||
async function readConfigVersion(db: DatabaseService): Promise<number> {
|
||||
const row = db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', VERSION_KEY);
|
||||
const parsed = row?.value ? Number.parseInt(row.value, 10) : 0;
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
|
||||
@@ -55,7 +55,7 @@ export class StorageStatsService {
|
||||
}
|
||||
|
||||
/** The stored row, parsed; null when absent or unparseable (logged, never a 500). */
|
||||
readUsage(): StorageUsage | null {
|
||||
async readUsage(): Promise<StorageUsage | null> {
|
||||
const row = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', USAGE_KEY);
|
||||
if (!row) return null;
|
||||
try {
|
||||
|
||||
@@ -12,7 +12,10 @@ export class StorageUsageScanJob implements OnApplicationBootstrap {
|
||||
private readonly stats: StorageStatsService,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
// `async` only so the sweep's call-graph gate sees a non-sync DB-reaching
|
||||
// frame here: nothing in the body awaits, and Nest awaits the returned
|
||||
// promise before the next bootstrap hook, exactly as it did the `void`.
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.registrar.isEnabled()) return;
|
||||
this.registrar.register('storage-usage-scan', '15 4 * * *', () => {
|
||||
void this.tick();
|
||||
|
||||
@@ -290,8 +290,8 @@ export class StorageService {
|
||||
* pre-restore one. Same shape as health(): StorageRegistryService stays
|
||||
* unexported from the module, this facade is the only way in.
|
||||
*/
|
||||
reloadConfig(): void {
|
||||
this.registry.reload();
|
||||
async reloadConfig(): Promise<void> {
|
||||
await this.registry.reload();
|
||||
}
|
||||
|
||||
private resolve(category: ServedCategory, name: string): Resolved {
|
||||
|
||||
@@ -39,38 +39,38 @@ export class TodoController {
|
||||
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { items: this.todo.listItems(tripId) };
|
||||
async list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { items: await this.todo.listItems(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Post()
|
||||
create(
|
||||
async create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: TodoCreateItemDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const { name, category, due_date, description, assigned_user_id, priority } = body;
|
||||
const item = this.todo.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
|
||||
const item = await this.todo.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
|
||||
this.todo.broadcast(tripId, 'todo:created', { item }, socketId);
|
||||
return { item };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
async reorder(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: TodoReorderDto,
|
||||
) {
|
||||
this.todo.reorderItems(tripId, body.orderedIds);
|
||||
await this.todo.reorderItems(tripId, body.orderedIds);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put(':id')
|
||||
update(
|
||||
async update(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@@ -80,7 +80,7 @@ export class TodoController {
|
||||
const { name, checked, category, due_date, description, assigned_user_id, priority } = body;
|
||||
// bodyKeys carries which keys the request actually provided (the null-clear
|
||||
// protocol); the parsed body only ever holds known schema keys.
|
||||
const updated = this.todo.updateItem(
|
||||
const updated = await this.todo.updateItem(
|
||||
tripId,
|
||||
id,
|
||||
// checked arrives as boolean or legacy 0/1 — normalize to the 0/1 the SQL binds.
|
||||
@@ -96,13 +96,13 @@ export class TodoController {
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Delete(':id')
|
||||
remove(
|
||||
async remove(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('id') id: string,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
if (!this.todo.deleteItem(tripId, id)) {
|
||||
if (!(await this.todo.deleteItem(tripId, id))) {
|
||||
throw new HttpException({ error: 'Item not found' }, 404);
|
||||
}
|
||||
this.todo.broadcast(tripId, 'todo:deleted', { itemId: Number(id) }, socketId);
|
||||
@@ -110,13 +110,13 @@ export class TodoController {
|
||||
}
|
||||
|
||||
@Get('category-assignees')
|
||||
categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { assignees: this.todo.getCategoryAssignees(tripId) };
|
||||
async categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
|
||||
return { assignees: await this.todo.getCategoryAssignees(tripId) };
|
||||
}
|
||||
|
||||
@RequirePermission('packing_edit')
|
||||
@Put('category-assignees/:categoryName')
|
||||
updateCategoryAssignees(
|
||||
async updateCategoryAssignees(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('categoryName') categoryName: string,
|
||||
@@ -124,7 +124,7 @@ export class TodoController {
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const category = decodeURIComponent(categoryName);
|
||||
const rows = this.todo.updateCategoryAssignees(tripId, category, body.user_ids);
|
||||
const rows = await this.todo.updateCategoryAssignees(tripId, category, body.user_ids);
|
||||
this.todo.broadcast(tripId, 'todo:assignees', { category, assignees: rows }, socketId);
|
||||
return { assignees: rows };
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ export class TodoMcp {
|
||||
access: { group: 'todos', mode: 'read' },
|
||||
})
|
||||
async listTodos({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
const items = this.todos.listItems(tripId);
|
||||
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
const items = await this.todos.listItems(tripId);
|
||||
return ok({ items });
|
||||
}
|
||||
|
||||
@@ -78,9 +78,9 @@ export class TodoMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const item = this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
|
||||
const item = await this.todos.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
|
||||
this.guards.safeBroadcast(tripId, 'todo:created', { item });
|
||||
return ok({ item });
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export class TodoMcp {
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// Build bodyKeys to signal which nullable fields were explicitly provided
|
||||
const bodyKeys: string[] = [];
|
||||
@@ -117,7 +117,7 @@ export class TodoMcp {
|
||||
if (description !== undefined) bodyKeys.push('description');
|
||||
if (assigned_user_id !== undefined) bodyKeys.push('assigned_user_id');
|
||||
if (priority !== undefined) bodyKeys.push('priority');
|
||||
const item = this.todos.updateItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys);
|
||||
const item = await this.todos.updateItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys);
|
||||
if (!item) return errorResult('To-do item not found.');
|
||||
this.guards.safeBroadcast(tripId, 'todo:updated', { item });
|
||||
return ok({ item });
|
||||
@@ -137,9 +137,9 @@ export class TodoMcp {
|
||||
})
|
||||
async toggleTodo({ tripId, itemId, checked }: { tripId: number; itemId: number; checked: boolean }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const item = this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
|
||||
const item = await this.todos.updateItem(tripId, itemId, { checked: checked ? 1 : 0 }, []);
|
||||
if (!item) return errorResult('To-do item not found.');
|
||||
this.guards.safeBroadcast(tripId, 'todo:updated', { item });
|
||||
return ok({ item });
|
||||
@@ -158,9 +158,9 @@ export class TodoMcp {
|
||||
})
|
||||
async deleteTodo({ tripId, itemId }: { tripId: number; itemId: number }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const deleted = this.todos.deleteItem(tripId, itemId);
|
||||
const deleted = await this.todos.deleteItem(tripId, itemId);
|
||||
if (!deleted) return errorResult('To-do item not found.');
|
||||
this.guards.safeBroadcast(tripId, 'todo:deleted', { itemId });
|
||||
return ok({ success: true });
|
||||
@@ -179,9 +179,9 @@ export class TodoMcp {
|
||||
})
|
||||
async reorderTodos({ tripId, orderedIds }: { tripId: number; orderedIds: number[] }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
this.todos.reorderItems(tripId, orderedIds);
|
||||
await this.todos.reorderItems(tripId, orderedIds);
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@@ -196,8 +196,8 @@ export class TodoMcp {
|
||||
access: { group: 'todos', mode: 'read' },
|
||||
})
|
||||
async getTodoCategoryAssignees({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
const assignees = this.todos.getCategoryAssignees(tripId);
|
||||
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
const assignees = await this.todos.getCategoryAssignees(tripId);
|
||||
return ok({ assignees });
|
||||
}
|
||||
|
||||
@@ -215,9 +215,9 @@ export class TodoMcp {
|
||||
})
|
||||
async setTodoCategoryAssignees({ tripId, categoryName, userIds }: { tripId: number; categoryName: string; userIds: number[] }, ctx: McpContext) {
|
||||
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
|
||||
if (!this.todos.verifyTripAccess(tripId, ctx.userId)) return noAccess();
|
||||
if (!(await this.todos.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('packing_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
const assignees = this.todos.updateCategoryAssignees(tripId, categoryName, userIds);
|
||||
const assignees = await this.todos.updateCategoryAssignees(tripId, categoryName, userIds);
|
||||
this.guards.safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees });
|
||||
return ok({ assignees });
|
||||
}
|
||||
@@ -232,7 +232,7 @@ export class TodoMcp {
|
||||
})
|
||||
async tripTodosResource(uri: URL, { tripId }: { tripId: string | string[] }, ctx: McpContext) {
|
||||
const id = parseId(tripId);
|
||||
if (id === null || !this.todos.verifyTripAccess(id, ctx.userId)) {
|
||||
if (id === null || !(await this.todos.verifyTripAccess(id, ctx.userId))) {
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
@@ -241,7 +241,7 @@ export class TodoMcp {
|
||||
}],
|
||||
};
|
||||
}
|
||||
const items = this.todos.listItems(id);
|
||||
const items = await this.todos.listItems(id);
|
||||
return {
|
||||
contents: [{
|
||||
uri: uri.href,
|
||||
|
||||
@@ -27,8 +27,12 @@ export class TodoRpc {
|
||||
) {}
|
||||
|
||||
@PluginMethod('todos.list', { permission: 'db:read:todos' })
|
||||
list(params: Record<string, unknown>, ctx: PluginRpcContext): unknown[] {
|
||||
return this.guards.tripRead(params, ctx, () => this.todos.listItems(String(num(params.tripId, 'tripId'))) as unknown[]);
|
||||
async list(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown[]> {
|
||||
return await this.guards.tripRead(
|
||||
params,
|
||||
ctx,
|
||||
async () => (await this.todos.listItems(String(num(params.tripId, 'tripId')))) as unknown[],
|
||||
);
|
||||
}
|
||||
|
||||
@PluginMethod('todos.create', { permission: 'db:write:todos' })
|
||||
@@ -38,7 +42,7 @@ export class TodoRpc {
|
||||
const input = asPayload(params.input);
|
||||
if (typeof input.name !== 'string' || input.name.trim() === '') throw new BadParams('todo name is required');
|
||||
await this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION);
|
||||
const item = this.todos.createItem(String(tripId), input as never);
|
||||
const item = await this.todos.createItem(String(tripId), input as never);
|
||||
this.realtime.broadcast(tripId, 'todo:created', { item }, undefined);
|
||||
return item;
|
||||
}
|
||||
@@ -50,7 +54,7 @@ export class TodoRpc {
|
||||
const actor = this.guards.requireActor(ctx, 'todo');
|
||||
await this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION);
|
||||
const input = asPayload(params.input);
|
||||
const updated = this.todos.updateItem(String(tripId), String(todoId), input as never, Object.keys(input));
|
||||
const updated = await this.todos.updateItem(String(tripId), String(todoId), input as never, Object.keys(input));
|
||||
if (!updated) throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`);
|
||||
this.realtime.broadcast(tripId, 'todo:updated', { item: updated }, undefined);
|
||||
return updated;
|
||||
@@ -62,7 +66,7 @@ export class TodoRpc {
|
||||
const todoId = num(params.todoId, 'todoId');
|
||||
const actor = this.guards.requireActor(ctx, 'todo');
|
||||
await this.guards.requireTripEdit(tripId, actor, TODO_EDIT_ACTION);
|
||||
if (!this.todos.deleteItem(String(tripId), String(todoId))) {
|
||||
if (!(await this.todos.deleteItem(String(tripId), String(todoId)))) {
|
||||
throw new ForbiddenResource(`no todo ${todoId} on trip ${tripId}`);
|
||||
}
|
||||
this.realtime.broadcast(tripId, 'todo:deleted', { itemId: todoId }, undefined);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import type { User } from '../../types';
|
||||
import { DatabaseService, type TripAccess } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
|
||||
type Trip = TripAccess;
|
||||
|
||||
@@ -22,9 +23,10 @@ export class TodoService {
|
||||
private readonly db: DatabaseService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
verifyTripAccess(tripId: string | number, userId: number) {
|
||||
async verifyTripAccess(tripId: string | number, userId: number) {
|
||||
return this.db.canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
@@ -36,14 +38,14 @@ export class TodoService {
|
||||
this.realtime.broadcast(tripId, event, payload, socketId);
|
||||
}
|
||||
|
||||
listItems(tripId: string | number) {
|
||||
async listItems(tripId: string | number) {
|
||||
return this.db.all(
|
||||
'SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC',
|
||||
tripId
|
||||
);
|
||||
}
|
||||
|
||||
createItem(tripId: string | number, data: {
|
||||
async createItem(tripId: string | number, data: {
|
||||
name: string; category?: string | null; due_date?: string | null; description?: string | null; assigned_user_id?: number | null; priority?: number;
|
||||
}) {
|
||||
const maxOrder = this.db.get<{ max: number | null }>('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?', tripId)!;
|
||||
@@ -58,7 +60,7 @@ export class TodoService {
|
||||
return this.db.get('SELECT * FROM todo_items WHERE id = ?', result.lastInsertRowid);
|
||||
}
|
||||
|
||||
updateItem(
|
||||
async updateItem(
|
||||
tripId: string | number,
|
||||
id: string | number,
|
||||
data: { name?: string; checked?: number; category?: string | null; due_date?: string | null; description?: string | null; assigned_user_id?: number | null; priority?: number | null },
|
||||
@@ -96,7 +98,7 @@ export class TodoService {
|
||||
return this.db.get('SELECT * FROM todo_items WHERE id = ?', id);
|
||||
}
|
||||
|
||||
deleteItem(tripId: string | number, id: string | number): boolean {
|
||||
async deleteItem(tripId: string | number, id: string | number): Promise<boolean> {
|
||||
const item = this.db.get('SELECT id FROM todo_items WHERE id = ? AND trip_id = ?', id, tripId);
|
||||
if (!item) return false;
|
||||
|
||||
@@ -104,16 +106,16 @@ export class TodoService {
|
||||
return true;
|
||||
}
|
||||
|
||||
reorderItems(tripId: string | number, orderedIds: number[]): void {
|
||||
async reorderItems(tripId: string | number, orderedIds: number[]): Promise<void> {
|
||||
const update = this.db.prepare('UPDATE todo_items SET sort_order = ? WHERE id = ? AND trip_id = ?');
|
||||
this.db.transaction(() => {
|
||||
await this.uow.transactional(async () => {
|
||||
orderedIds.forEach((id, index) => {
|
||||
update.run(index, id, tripId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getCategoryAssignees(tripId: string | number) {
|
||||
async getCategoryAssignees(tripId: string | number) {
|
||||
const rows = this.db.all<{ category_name: string; user_id: number; username: string; avatar: string | null }>(`
|
||||
SELECT tca.category_name, tca.user_id, u.username, u.avatar
|
||||
FROM todo_category_assignees tca
|
||||
@@ -130,8 +132,8 @@ export class TodoService {
|
||||
return assignees;
|
||||
}
|
||||
|
||||
updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
|
||||
this.db.transaction(() => {
|
||||
async updateCategoryAssignees(tripId: string | number, categoryName: string, userIds: number[] | undefined) {
|
||||
await this.uow.transactional(async () => {
|
||||
this.db.run('DELETE FROM todo_category_assignees WHERE trip_id = ? AND category_name = ?', tripId, categoryName);
|
||||
|
||||
if (Array.isArray(userIds) && userIds.length > 0) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class TripInviteLinkController {
|
||||
constructor(private readonly invites: TripInviteService, private readonly audit: AuditService) {}
|
||||
|
||||
private async requireManage(tripId: string, user: User) {
|
||||
const trip = this.invites.verifyTripAccess(tripId, user.id);
|
||||
const trip = await this.invites.verifyTripAccess(tripId, user.id);
|
||||
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
|
||||
if (!(await this.invites.canManage(trip, user))) throw new HttpException({ error: 'No permission' }, 403);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export class TripInviteLinkController {
|
||||
// The token grants trip membership, so reading it needs the same
|
||||
// share_manage permission as creating/rotating it — not just trip access.
|
||||
await this.requireManage(tripId, user);
|
||||
const info = this.invites.get(tripId);
|
||||
const info = await this.invites.get(tripId);
|
||||
return info ? info : { token: null };
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class TripInviteLinkController {
|
||||
const days = body?.expires_in_days != null && String(body.expires_in_days).trim() !== ''
|
||||
? Number.parseInt(String(body.expires_in_days))
|
||||
: null;
|
||||
const info = this.invites.createOrRotate(tripId, user.id, Number.isFinite(days as number) ? days : null);
|
||||
const info = await this.invites.createOrRotate(tripId, user.id, Number.isFinite(days as number) ? days : null);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_create', resource: tripId, ip: getClientIp(req), details: { expires_in_days: days } });
|
||||
return info;
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export class TripInviteLinkController {
|
||||
@Delete()
|
||||
async remove(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) {
|
||||
await this.requireManage(tripId, user);
|
||||
this.invites.remove(tripId);
|
||||
await this.invites.remove(tripId);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_delete', resource: tripId, ip: getClientIp(req) });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -82,9 +82,9 @@ export class TripInviteController {
|
||||
}
|
||||
|
||||
@Get(':token')
|
||||
preview(@Param('token') token: string, @Req() req: Request) {
|
||||
async preview(@Param('token') token: string, @Req() req: Request) {
|
||||
this.limit(req, 30);
|
||||
const resolved = this.invites.resolve(token);
|
||||
const resolved = await this.invites.resolve(token);
|
||||
if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404);
|
||||
return { trip_id: resolved.trip_id, title: resolved.title };
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export class TripInviteController {
|
||||
@HttpCode(200)
|
||||
async accept(@CurrentUser() user: User, @Param('token') token: string, @Req() req: Request) {
|
||||
this.limit(req, 20);
|
||||
const resolved = this.invites.resolve(token);
|
||||
const resolved = await this.invites.resolve(token);
|
||||
if (!resolved) throw new HttpException({ error: 'Invalid or expired invite link' }, 404);
|
||||
const result = await this.invites.join(resolved.trip_id, user.id);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'trip.invite_link_join', resource: String(resolved.trip_id), ip: getClientIp(req), details: { joined: result.joined } });
|
||||
|
||||
@@ -47,7 +47,7 @@ export class TripInviteMcp {
|
||||
|
||||
/** Trip access first (404-equivalent), then share_manage, which is requireManage() in the controller. */
|
||||
private async denyManage(tripId: number, userId: number) {
|
||||
if (!this.invites.verifyTripAccess(String(tripId), userId)) return noAccess();
|
||||
if (!(await this.invites.verifyTripAccess(String(tripId), userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('share_manage', tripId, userId))) return permissionDenied();
|
||||
return null;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export class TripInviteMcp {
|
||||
async getTripInviteLink({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
const denied = await this.denyManage(tripId, ctx.userId);
|
||||
if ((await denied)) return denied;
|
||||
const info = this.invites.get(tripId);
|
||||
const info = await this.invites.get(tripId);
|
||||
return ok({ invite_link: info ? this.describe(info) : null });
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export class TripInviteMcp {
|
||||
? Number.parseInt(String(expires_in_days))
|
||||
: null;
|
||||
const days = Number.isFinite(parsed as number) ? parsed : null;
|
||||
const info = this.invites.createOrRotate(tripId, ctx.userId, days);
|
||||
const info = await this.invites.createOrRotate(tripId, ctx.userId, days);
|
||||
// Minting a membership credential is audited wherever it happens, so an
|
||||
// admin reading the log sees the same row for a link made through an
|
||||
// assistant as for one made in the planner. No request here, hence no ip.
|
||||
@@ -125,7 +125,7 @@ export class TripInviteMcp {
|
||||
if (await this.isDemoUser(ctx.userId)) return demoDenied();
|
||||
const denied = await this.denyManage(tripId, ctx.userId);
|
||||
if ((await denied)) return denied;
|
||||
this.invites.remove(tripId);
|
||||
await this.invites.remove(tripId);
|
||||
await this.audit.writeAudit({
|
||||
userId: ctx.userId,
|
||||
action: 'trip.invite_link_delete',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import crypto from 'crypto';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import type { TripAccess } from '../database/database.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { TripMembershipService } from '../trip-membership/trip-membership.service';
|
||||
@@ -32,9 +33,10 @@ export class TripInviteService {
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly membership: TripMembershipService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
async verifyTripAccess(tripId: string, userId: number) {
|
||||
return this.dbs.canAccessTrip(tripId, userId);
|
||||
}
|
||||
|
||||
@@ -43,7 +45,7 @@ export class TripInviteService {
|
||||
}
|
||||
|
||||
/** The current invite link for a trip, or null if none exists. */
|
||||
get(tripId: string | number): TripInviteInfo | null {
|
||||
async get(tripId: string | number): Promise<TripInviteInfo | null> {
|
||||
const row = this.dbs.get<{ token: string; expires_at: string | null; created_at: string }>(
|
||||
'SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?',
|
||||
tripId,
|
||||
@@ -55,7 +57,7 @@ export class TripInviteService {
|
||||
* Create the trip's invite link, or rotate it to a fresh token (there is only
|
||||
* ever one row per trip). An optional expiry (days) can bound the link's life.
|
||||
*/
|
||||
createOrRotate(tripId: string | number, createdBy: number, expiresInDays?: number | null): TripInviteInfo {
|
||||
async createOrRotate(tripId: string | number, createdBy: number, expiresInDays?: number | null): Promise<TripInviteInfo> {
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
// Any non-positive/absent value (0, negatives, NaN, null, undefined) means
|
||||
// "no expiry" — deliberate: the UI sends null when no bound was chosen.
|
||||
@@ -67,7 +69,7 @@ export class TripInviteService {
|
||||
// Probe + write + re-select are one atomic unit so a concurrent rotation
|
||||
// can't interleave between them (the trip_id UNIQUE constraint would turn
|
||||
// that into a spurious 500).
|
||||
return this.dbs.transaction(() => {
|
||||
return await this.uow.transactional(async () => {
|
||||
const existing = this.dbs.get('SELECT id FROM trip_invite_tokens WHERE trip_id = ?', tripId);
|
||||
if (existing) {
|
||||
this.dbs.run(
|
||||
@@ -80,12 +82,12 @@ export class TripInviteService {
|
||||
tripId, token, createdBy, expiresAt,
|
||||
);
|
||||
}
|
||||
return this.get(tripId)!;
|
||||
return (await this.get(tripId))!;
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove the trip's invite link entirely (disable). */
|
||||
remove(tripId: string | number): void {
|
||||
async remove(tripId: string | number): Promise<void> {
|
||||
this.dbs.run('DELETE FROM trip_invite_tokens WHERE trip_id = ?', tripId);
|
||||
}
|
||||
|
||||
@@ -95,7 +97,7 @@ export class TripInviteService {
|
||||
* trip title is safe to return for the join confirmation screen; an anonymous
|
||||
* caller never reaches this (the endpoint is JWT-guarded).
|
||||
*/
|
||||
resolve(token: string): { trip_id: number; title: string } | null {
|
||||
async resolve(token: string): Promise<{ trip_id: number; title: string } | null> {
|
||||
const row = this.dbs.get<{ trip_id: number; title: string; expires_at: string | null }>(
|
||||
`SELECT t.id AS trip_id, t.title AS title, ti.expires_at AS expires_at
|
||||
FROM trip_invite_tokens ti
|
||||
@@ -114,5 +116,5 @@ export class TripInviteService {
|
||||
|
||||
/** Join the resolved trip as the current (authenticated, non-guest) user.
|
||||
* invited_by is null — they joined via a link, not a personal invite. */
|
||||
join(tripId: number, userId: number) { return this.membership.joinTripAsMember(tripId, userId, null); }
|
||||
async join(tripId: number, userId: number) { return await this.membership.joinTripAsMember(tripId, userId, null); }
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export class TripReadModelService {
|
||||
// Scope to the requesting member so other members' private packing items
|
||||
// (#858) never land in this viewer's offline cache.
|
||||
packingItems: this.packing.listItems(tripId, viewerId),
|
||||
todoItems: this.todo.listItems(tripId),
|
||||
todoItems: await this.todo.listItems(tripId),
|
||||
budgetItems: await this.budget.listBudgetItems(tripId),
|
||||
reservations: this.reservations.list(tripId),
|
||||
files: this.files.listFiles(tripId, false),
|
||||
|
||||
@@ -280,7 +280,7 @@ export class TripsMcp {
|
||||
const canReadCollab = collabEnabled && canRead(ctx.scopes, 'collab');
|
||||
const canReadTodos = packingEnabled && canRead(ctx.scopes, 'todos');
|
||||
const canReadRes = canRead(ctx.scopes, 'reservations');
|
||||
const todos = canReadTodos ? this.todos.listItems(tripId) : [];
|
||||
const todos = canReadTodos ? await this.todos.listItems(tripId) : [];
|
||||
let pollCount = 0;
|
||||
let messageCount = 0;
|
||||
if (canReadCollab) {
|
||||
|
||||
@@ -149,7 +149,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
);
|
||||
const queryHelpersService = new QueryHelpersService(dbService);
|
||||
const daysService = new DaysService(dbService, permissionsService, realtimeService, queryHelpersService, await createTestUnitOfWork(dbService.connection));
|
||||
const todoService = new TodoService(dbService, permissionsService, realtimeService);
|
||||
const todoService = new TodoService(dbService, permissionsService, realtimeService, await createTestUnitOfWork(dbService.connection));
|
||||
const packingService = new PackingService(dbService, permissionsService, realtimeService, notificationsStub());
|
||||
const collabService = new CollabService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage, new RateLimitService());
|
||||
// Exactly one instance, shared by maps, places and share: its stampede guard
|
||||
@@ -235,7 +235,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
|
||||
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
|
||||
new ShareMcp(new ShareService(dbService, new SettingsService(dbService, await createTestUnitOfWork(dbService.connection)), permissionsService, queryHelpersService, placePhotoCache), authService, guards),
|
||||
new FeedsMcp(new FeedsService(dbService, calendarService), dbService, new RuntimeEnvService(), guards),
|
||||
new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)),
|
||||
new TripInviteMcp(new TripInviteService(dbService, permissionsService, new TripMembershipService(dbService), await createTestUnitOfWork(dbService.connection)), dbService, new RuntimeEnvService(), guards, new AuditService(dbService)),
|
||||
new MapsMcp(mapsService),
|
||||
new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain, assignmentsService, guards, await createTestUnitOfWork(dbService.connection)),
|
||||
new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService),
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
|
||||
const budget = new BudgetService(dbs, permissions, exchangeRates, realtime, await createTestUnitOfWork(dbs.connection));
|
||||
const addons = new AddonsService(dbs);
|
||||
const queryHelpers = new QueryHelpersService(dbs);
|
||||
const todos = new TodoService(dbs, permissions, realtime);
|
||||
const todos = new TodoService(dbs, permissions, realtime, await createTestUnitOfWork(dbs.connection));
|
||||
const packing = new PackingService(dbs, permissions, realtime, notificationsStub());
|
||||
const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage);
|
||||
const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService());
|
||||
|
||||
@@ -48,7 +48,7 @@ const oauthSvc = new OauthService(oauthDbs, new AddonsService(oauthDbs), new Aud
|
||||
async function mintOauthToken(userId: number, audience: string | null, scopes: string[] = ['trips:read']): Promise<{ accessToken: string; clientId: string }> {
|
||||
const created = await oauthSvc.createOAuthClient(userId, 'MCP Test Client', ['https://client.example.com/cb'], scopes);
|
||||
const clientId = (created.client as { client_id: string }).client_id;
|
||||
const tokens = oauthSvc.issueTokens(clientId, userId, scopes, null, audience);
|
||||
const tokens = await oauthSvc.issueTokens(clientId, userId, scopes, null, audience);
|
||||
return { accessToken: tokens.access_token, clientId };
|
||||
}
|
||||
|
||||
@@ -465,8 +465,8 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = await oauthSvc.createOAuthClient(user.id, 'Scope Test Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']);
|
||||
const clientId = (created.client as { client_id: string }).client_id;
|
||||
const wide = oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE);
|
||||
const narrow = oauthSvc.issueTokens(clientId, user.id, ['trips:read'], null, MCP_AUDIENCE);
|
||||
const wide = await oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE);
|
||||
const narrow = await oauthSvc.issueTokens(clientId, user.id, ['trips:read'], null, MCP_AUDIENCE);
|
||||
const sessionId = await createSession(wide.access_token);
|
||||
|
||||
// The tool surface was registered from the wide set; resuming with the
|
||||
@@ -485,8 +485,8 @@ describe('MCP transport parity pins (Nest-hosted /mcp)', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = await oauthSvc.createOAuthClient(user.id, 'Scope Order Client', ['https://client.example.com/cb'], ['trips:read', 'trips:write']);
|
||||
const clientId = (created.client as { client_id: string }).client_id;
|
||||
const first = oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE);
|
||||
const reordered = oauthSvc.issueTokens(clientId, user.id, ['trips:write', 'trips:read'], null, MCP_AUDIENCE);
|
||||
const first = await oauthSvc.issueTokens(clientId, user.id, ['trips:read', 'trips:write'], null, MCP_AUDIENCE);
|
||||
const reordered = await oauthSvc.issueTokens(clientId, user.id, ['trips:write', 'trips:read'], null, MCP_AUDIENCE);
|
||||
const sessionId = await createSession(first.access_token);
|
||||
|
||||
const res = await request(app)
|
||||
|
||||
@@ -49,9 +49,11 @@ import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
// oauth.pending-codes.ts, so the routes under test see every code written here.
|
||||
const oauthDbs = new DatabaseService(testDb);
|
||||
const containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(oauthDbs));
|
||||
const createAuthCode = containerSideOauth.createAuthCode.bind(containerSideOauth);
|
||||
const createOAuthClient = containerSideOauth.createOAuthClient.bind(containerSideOauth);
|
||||
const getUserByAccessToken = containerSideOauth.getUserByAccessToken.bind(containerSideOauth);
|
||||
// Arrow wrappers rather than `.bind`: with `strictBindCallApply: false` a bound
|
||||
// alias is typed `any`, which hides a missing `await` on the now-async methods.
|
||||
const createAuthCode = (...args: Parameters<OauthService['createAuthCode']>) => containerSideOauth.createAuthCode(...args);
|
||||
const createOAuthClient = (...args: Parameters<OauthService['createOAuthClient']>) => containerSideOauth.createOAuthClient(...args);
|
||||
const getUserByAccessToken = (...args: Parameters<OauthService['getUserByAccessToken']>) => containerSideOauth.getUserByAccessToken(...args);
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
@@ -1584,7 +1586,7 @@ describe('POST /oauth/token — client_credentials grant', () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const info = getUserByAccessToken(res.body.access_token);
|
||||
const info = await getUserByAccessToken(res.body.access_token);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.user.id).toBe(user.id);
|
||||
expect(info!.scopes).toEqual(['trips:read']);
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { RuntimeEnvService } from '../../src/nest/app-config/runtime-env.se
|
||||
import { StorageEventsService } from '../../src/nest/storage/storage-events.service';
|
||||
import { BACKENDS_KEY, CATEGORIES_KEY, StorageRegistryService } from '../../src/nest/storage/storage-registry.service';
|
||||
import { StorageService } from '../../src/nest/storage/storage.service';
|
||||
import { createTestUnitOfWork } from '../helpers/test-uow';
|
||||
|
||||
const testDb = new Database(':memory:');
|
||||
testDb.exec('PRAGMA journal_mode = WAL');
|
||||
@@ -80,8 +81,8 @@ describe('C6 — restore reloads the storage registry (audit #4)', () => {
|
||||
]);
|
||||
setSetting(CATEGORIES_KEY, { files: 'nas-a' });
|
||||
|
||||
const registry = new StorageRegistryService(db, envStub(), new StorageEventsService());
|
||||
registry.onModuleInit();
|
||||
const registry = new StorageRegistryService(db, envStub(), new StorageEventsService(), await createTestUnitOfWork(testDb));
|
||||
await registry.onModuleInit();
|
||||
const storage = new StorageService(registry);
|
||||
|
||||
expect(registry.resolve('files').backendName).toBe('nas-a');
|
||||
@@ -105,7 +106,7 @@ describe('C6 — restore reloads the storage registry (audit #4)', () => {
|
||||
// The fix under test: StorageService.reloadConfig(), the narrow passthrough
|
||||
// restoreFromZip now calls right after reinitialize() and right before
|
||||
// rehydration (see backup.impl.ts).
|
||||
storage.reloadConfig();
|
||||
await storage.reloadConfig();
|
||||
|
||||
expect(registry.resolve('files').backendName).toBe('nas-b');
|
||||
|
||||
|
||||
@@ -562,7 +562,7 @@ describe('AddonsService place shadow flag', () => {
|
||||
expect(await svc().getPlaceShadow()).toEqual({ enabled: expected });
|
||||
expect(dbMock._stmt.get).toHaveBeenLastCalledWith('place_shadow_enabled');
|
||||
|
||||
expect(shadow.enabled()).toBe(expected);
|
||||
expect(await shadow.enabled()).toBe(expected);
|
||||
expect(dbMock.prepare).toHaveBeenLastCalledWith(
|
||||
"SELECT value FROM app_settings WHERE key = 'place_shadow_enabled'",
|
||||
);
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('AdminController read-only getters', () => {
|
||||
expect(adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] });
|
||||
expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
|
||||
expect(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
|
||||
expect(adminCtl(svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) }).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
|
||||
expect(await adminCtl(svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockResolvedValue([{ id: 1 }]) }).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -172,18 +172,18 @@ describe('BackupController', () => {
|
||||
expect(thrown(() => bc(svc(), job({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) })).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', () => {
|
||||
it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
process.env.NODE_ENV = 'development';
|
||||
const r = thrown(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) })).updateAutoSettings(user, {}, req));
|
||||
const r = await thrownAsync(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) })).updateAutoSettings(user, {}, req));
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => {
|
||||
it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
process.env.NODE_ENV = 'production';
|
||||
const r = thrown(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) })).updateAutoSettings(user, {}, req));
|
||||
const r = await thrownAsync(() => bc(svc(), job({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) })).updateAutoSettings(user, {}, req));
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined });
|
||||
});
|
||||
|
||||
@@ -135,8 +135,8 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
});
|
||||
|
||||
it('PUT /:id 403 without file_edit, 404 unknown, else updates + broadcasts', async () => {
|
||||
expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).update(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission to edit files' } });
|
||||
expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).update(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(await rejected(fc(fsvc({ can: vi.fn().mockReturnValue(false) })).update(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission to edit files' } });
|
||||
expect(await rejected(fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).update(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const updateFile = vi.fn().mockReturnValue({ id: 9 });
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, description: 'x' }), updateFile, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(await fc(s).update(user, trip, '5', '9', { description: 'new' })).toEqual({ file: { id: 9 } });
|
||||
@@ -147,7 +147,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
getFileById: vi.fn().mockReturnValue({ id: 9 }),
|
||||
findForeignLinkTarget: vi.fn().mockReturnValue('budget_item_id'),
|
||||
});
|
||||
expect(thrown(() => fc(rejectSvc).update(user, trip, '5', '9', { budget_item_id: '99' }))).toEqual({
|
||||
expect(await rejected(fc(rejectSvc).update(user, trip, '5', '9', { budget_item_id: '99' }))).toEqual({
|
||||
status: 400,
|
||||
body: { error: 'Linked item does not belong to this trip' },
|
||||
});
|
||||
@@ -168,8 +168,8 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
});
|
||||
|
||||
it('PATCH /:id/star 403/404, else toggles', async () => {
|
||||
expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).star(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).star(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(await rejected(fc(fsvc({ can: vi.fn().mockReturnValue(false) })).star(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(await rejected(fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).star(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const toggleStarred = vi.fn().mockReturnValue({ id: 9, starred: 1 });
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, starred: 0 }), toggleStarred, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(await fc(s).star(user, trip, '5', '9')).toEqual({ file: { id: 9, starred: 1 } });
|
||||
@@ -177,8 +177,8 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
});
|
||||
|
||||
it('DELETE /:id soft-delete 403/404, else success', async () => {
|
||||
expect(thrown(() => fc(fsvc({ can: vi.fn().mockReturnValue(false) })).remove(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission to delete files' } });
|
||||
expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).remove(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(await rejected(fc(fsvc({ can: vi.fn().mockReturnValue(false) })).remove(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission to delete files' } });
|
||||
expect(await rejected(fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).remove(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const softDeleteFile = vi.fn();
|
||||
const broadcast = vi.fn();
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), softDeleteFile, broadcast } as Partial<FilesService>);
|
||||
@@ -187,7 +187,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
});
|
||||
|
||||
it('POST /:id/restore 404 not in trash, else restores', async () => {
|
||||
expect(thrown(() => fc(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).restore(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found in trash' } });
|
||||
expect(await rejected(fc(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).restore(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found in trash' } });
|
||||
const restoreFile = vi.fn().mockReturnValue({ id: 9 });
|
||||
const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), restoreFile, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(await fc(s).restore(user, trip, '5', '9')).toEqual({ file: { id: 9 } });
|
||||
@@ -207,7 +207,7 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
});
|
||||
|
||||
it('POST /:id/link 404 unknown file, else links', async () => {
|
||||
expect(thrown(() => fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).link(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(await rejected(fc(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).link(user, trip, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const createFileLink = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), createFileLink } as Partial<FilesService>);
|
||||
expect(await fc(s).link(user, trip, '5', '9', { reservation_id: 2 })).toEqual({ success: true, links: [{ id: 1 }] });
|
||||
@@ -221,12 +221,13 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
expect(fc(s).links(user, trip, '5', '9')).toEqual({ links: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('the link routes resolve the file against :tripId, so a foreign file is 404', () => {
|
||||
it('the link routes resolve the file against :tripId, so a foreign file is 404', async () => {
|
||||
const foreign = () => fsvc({ getFileById: vi.fn().mockReturnValue(undefined), deleteFileLink: vi.fn(), getFileLinks: vi.fn() } as Partial<FilesService>);
|
||||
const unlinkSvc = foreign();
|
||||
expect(thrown(() => fc(unlinkSvc).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(await rejected(fc(unlinkSvc).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(unlinkSvc.deleteFileLink).not.toHaveBeenCalled();
|
||||
const listSvc = foreign();
|
||||
// `links` is still a synchronous handler, so it throws rather than rejecting.
|
||||
expect(thrown(() => fc(listSvc).links(user, trip, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(listSvc.getFileLinks).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -234,9 +235,9 @@ describe('FilesController (parity with the legacy /api/trips/:tripId/files route
|
||||
it('the trash + link routes all reject without file_delete / file_edit', async () => {
|
||||
const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) });
|
||||
await expect(fc(denied()).permanent(user, trip, '5', '9')).rejects.toMatchObject({ status: 403 });
|
||||
expect(thrown(() => fc(denied()).restore(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => fc(denied()).link(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => fc(denied()).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(await rejected(fc(denied()).restore(user, trip, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(await rejected(fc(denied()).link(user, trip, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(await rejected(fc(denied()).unlink(user, trip, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -81,12 +81,12 @@ describe('hasTripPermission', () => {
|
||||
});
|
||||
|
||||
describe('isAdminUser', () => {
|
||||
it('GRD-010: reflects the users.role column and is false for unknown ids', () => {
|
||||
it('GRD-010: reflects the users.role column and is false for unknown ids', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(svc.isAdminUser(user.id)).toBe(false);
|
||||
expect(await svc.isAdminUser(user.id)).toBe(false);
|
||||
testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(user.id);
|
||||
expect(svc.isAdminUser(user.id)).toBe(true);
|
||||
expect(svc.isAdminUser(424242)).toBe(false);
|
||||
expect(await svc.isAdminUser(user.id)).toBe(true);
|
||||
expect(await svc.isAdminUser(424242)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -155,43 +155,43 @@ describe('armSseKeepalive', () => {
|
||||
});
|
||||
|
||||
describe('McpTransportService.verifyToken', () => {
|
||||
it('MCPT-010: rejects a missing header, a schemeless token, and non-Bearer schemes', () => {
|
||||
it('MCPT-010: rejects a missing header, a schemeless token, and non-Bearer schemes', async () => {
|
||||
const { svc } = makeService();
|
||||
expect(svc.verifyToken(undefined)).toBeNull();
|
||||
expect(svc.verifyToken('token-without-scheme')).toBeNull();
|
||||
expect(svc.verifyToken('Basic dXNlcjpwdw==')).toBeNull();
|
||||
expect(svc.verifyToken('Bearer ')).toBeNull();
|
||||
expect(await svc.verifyToken(undefined)).toBeNull();
|
||||
expect(await svc.verifyToken('token-without-scheme')).toBeNull();
|
||||
expect(await svc.verifyToken('Basic dXNlcjpwdw==')).toBeNull();
|
||||
expect(await svc.verifyToken('Bearer ')).toBeNull();
|
||||
});
|
||||
|
||||
it('MCPT-011: trekoa_ tokens resolve through OauthService and enforce the RFC 8707 audience', () => {
|
||||
it('MCPT-011: trekoa_ tokens resolve through OauthService and enforce the RFC 8707 audience', async () => {
|
||||
const info = { user, scopes: ['trips:read'], clientId: 'cid-1', audience: 'https://trek.example.test/mcp' };
|
||||
const { svc, oauth } = makeService({ oauth: { getUserByAccessToken: vi.fn(() => info) } });
|
||||
expect(svc.verifyToken('Bearer trekoa_x')).toEqual({
|
||||
const { svc, oauth } = makeService({ oauth: { getUserByAccessToken: vi.fn(async () => info) } });
|
||||
expect(await svc.verifyToken('Bearer trekoa_x')).toEqual({
|
||||
user, scopes: ['trips:read'], clientId: 'cid-1', isStaticToken: false,
|
||||
});
|
||||
expect(oauth.getUserByAccessToken).toHaveBeenCalledWith('trekoa_x');
|
||||
|
||||
const wrongAudience = makeService({
|
||||
oauth: { getUserByAccessToken: vi.fn(() => ({ ...info, audience: 'https://other.example.com' })) },
|
||||
oauth: { getUserByAccessToken: vi.fn(async () => ({ ...info, audience: 'https://other.example.com' })) },
|
||||
});
|
||||
expect(wrongAudience.svc.verifyToken('Bearer trekoa_x')).toBeNull();
|
||||
expect(await wrongAudience.svc.verifyToken('Bearer trekoa_x')).toBeNull();
|
||||
|
||||
const unknown = makeService();
|
||||
expect(unknown.svc.verifyToken('Bearer trekoa_dead')).toBeNull();
|
||||
expect(await unknown.svc.verifyToken('Bearer trekoa_dead')).toBeNull();
|
||||
});
|
||||
|
||||
it('MCPT-012: trek_ static tokens resolve through TokenService with full access + notice flag', () => {
|
||||
it('MCPT-012: trek_ static tokens resolve through TokenService with full access + notice flag', async () => {
|
||||
const { svc, tokens } = makeService({ tokens: { verifyMcpToken: vi.fn(() => user) } });
|
||||
expect(svc.verifyToken('Bearer trek_x')).toEqual({ user, scopes: null, clientId: null, isStaticToken: true });
|
||||
expect(await svc.verifyToken('Bearer trek_x')).toEqual({ user, scopes: null, clientId: null, isStaticToken: true });
|
||||
expect(tokens.verifyMcpToken).toHaveBeenCalledWith('trek_x');
|
||||
expect(makeService().svc.verifyToken('Bearer trek_dead')).toBeNull();
|
||||
expect(await makeService().svc.verifyToken('Bearer trek_dead')).toBeNull();
|
||||
});
|
||||
|
||||
it('MCPT-013: everything else falls back to the JWT path', () => {
|
||||
it('MCPT-013: everything else falls back to the JWT path', async () => {
|
||||
const { svc, auth } = makeService({ auth: { verifyJwtToken: vi.fn(() => user) } });
|
||||
expect(svc.verifyToken('Bearer some.jwt.token')).toEqual({ user, scopes: null, clientId: null, isStaticToken: false });
|
||||
expect(await svc.verifyToken('Bearer some.jwt.token')).toEqual({ user, scopes: null, clientId: null, isStaticToken: false });
|
||||
expect(auth.verifyJwtToken).toHaveBeenCalledWith('some.jwt.token');
|
||||
expect(makeService().svc.verifyToken('Bearer not-a-jwt')).toBeNull();
|
||||
expect(await makeService().svc.verifyToken('Bearer not-a-jwt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -72,26 +72,28 @@ const dbs = new DatabaseService(testDb);
|
||||
const addonsStub = { isAddonEnabled } as unknown as AddonsService;
|
||||
const svc = new OauthService(dbs, addonsStub, new AuditService(dbs));
|
||||
|
||||
// Legacy free-function names bound to the service, so the moved cases below read
|
||||
// exactly as they did before the fold.
|
||||
const createOAuthClient = svc.createOAuthClient.bind(svc);
|
||||
const listOAuthClients = svc.listOAuthClients.bind(svc);
|
||||
const deleteOAuthClient = svc.deleteOAuthClient.bind(svc);
|
||||
const rotateOAuthClientSecret = svc.rotateOAuthClientSecret.bind(svc);
|
||||
const createAuthCode = svc.createAuthCode.bind(svc);
|
||||
const consumeAuthCode = svc.consumeAuthCode.bind(svc);
|
||||
const issueTokens = svc.issueTokens.bind(svc);
|
||||
const getUserByAccessToken = svc.getUserByAccessToken.bind(svc);
|
||||
const refreshTokens = svc.refreshTokens.bind(svc);
|
||||
const revokeToken = svc.revokeToken.bind(svc);
|
||||
const listOAuthSessions = svc.listOAuthSessions.bind(svc);
|
||||
const revokeSession = svc.revokeSession.bind(svc);
|
||||
const validateAuthorizeRequest = svc.validateAuthorizeRequest.bind(svc);
|
||||
const verifyPKCE = svc.verifyPKCE.bind(svc);
|
||||
const authenticateClient = svc.authenticateClient.bind(svc);
|
||||
const saveConsent = svc.saveConsent.bind(svc);
|
||||
const getConsent = svc.getConsent.bind(svc);
|
||||
const isConsentSufficient = svc.isConsentSufficient.bind(svc);
|
||||
// Legacy free-function names delegating to the service, so the moved cases below
|
||||
// read exactly as they did before the fold. Arrow wrappers rather than `.bind`:
|
||||
// with `strictBindCallApply: false` a bound alias is typed `any`, which would
|
||||
// hide every missing `await` on the now-async methods from the type checker.
|
||||
const createOAuthClient = (...args: Parameters<OauthService['createOAuthClient']>) => svc.createOAuthClient(...args);
|
||||
const listOAuthClients = (...args: Parameters<OauthService['listOAuthClients']>) => svc.listOAuthClients(...args);
|
||||
const deleteOAuthClient = (...args: Parameters<OauthService['deleteOAuthClient']>) => svc.deleteOAuthClient(...args);
|
||||
const rotateOAuthClientSecret = (...args: Parameters<OauthService['rotateOAuthClientSecret']>) => svc.rotateOAuthClientSecret(...args);
|
||||
const createAuthCode = (...args: Parameters<OauthService['createAuthCode']>) => svc.createAuthCode(...args);
|
||||
const consumeAuthCode = (...args: Parameters<OauthService['consumeAuthCode']>) => svc.consumeAuthCode(...args);
|
||||
const issueTokens = (...args: Parameters<OauthService['issueTokens']>) => svc.issueTokens(...args);
|
||||
const getUserByAccessToken = (...args: Parameters<OauthService['getUserByAccessToken']>) => svc.getUserByAccessToken(...args);
|
||||
const refreshTokens = (...args: Parameters<OauthService['refreshTokens']>) => svc.refreshTokens(...args);
|
||||
const revokeToken = (...args: Parameters<OauthService['revokeToken']>) => svc.revokeToken(...args);
|
||||
const listOAuthSessions = (...args: Parameters<OauthService['listOAuthSessions']>) => svc.listOAuthSessions(...args);
|
||||
const revokeSession = (...args: Parameters<OauthService['revokeSession']>) => svc.revokeSession(...args);
|
||||
const validateAuthorizeRequest = (...args: Parameters<OauthService['validateAuthorizeRequest']>) => svc.validateAuthorizeRequest(...args);
|
||||
const verifyPKCE = (...args: Parameters<OauthService['verifyPKCE']>) => svc.verifyPKCE(...args);
|
||||
const authenticateClient = (...args: Parameters<OauthService['authenticateClient']>) => svc.authenticateClient(...args);
|
||||
const saveConsent = (...args: Parameters<OauthService['saveConsent']>) => svc.saveConsent(...args);
|
||||
const getConsent = (...args: Parameters<OauthService['getConsent']>) => svc.getConsent(...args);
|
||||
const isConsentSufficient = (...args: Parameters<OauthService['isConsentSufficient']>) => svc.isConsentSufficient(...args);
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -273,15 +275,15 @@ describe('createOAuthClient', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('listOAuthClients', () => {
|
||||
it('returns empty array for user with no clients', () => {
|
||||
it('returns empty array for user with no clients', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
expect(listOAuthClients(user.id)).toEqual([]);
|
||||
expect(await listOAuthClients(user.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns created clients with redirect_uris and allowed_scopes as arrays', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await makeClient(user.id, { name: 'Client A', redirectUris: ['https://a.com/cb'], scopes: ['trips:read', 'budget:read'] });
|
||||
const clients = listOAuthClients(user.id);
|
||||
const clients = await listOAuthClients(user.id);
|
||||
expect(clients).toHaveLength(1);
|
||||
expect(clients[0].name).toBe('Client A');
|
||||
expect(Array.isArray(clients[0].redirect_uris)).toBe(true);
|
||||
@@ -301,7 +303,7 @@ describe('deleteOAuthClient', () => {
|
||||
const clientRowId = created.client!.id as string;
|
||||
const result = await deleteOAuthClient(user.id, clientRowId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(listOAuthClients(user.id)).toHaveLength(0);
|
||||
expect(await listOAuthClients(user.id)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent client', async () => {
|
||||
@@ -345,12 +347,12 @@ describe('rotateOAuthClientSecret', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
expect(getUserByAccessToken(access_token)).not.toBeNull();
|
||||
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
expect(await getUserByAccessToken(access_token)).not.toBeNull();
|
||||
|
||||
await rotateOAuthClientSecret(user.id, created.client!.id as string);
|
||||
|
||||
expect(getUserByAccessToken(access_token)).toBeNull();
|
||||
expect(await getUserByAccessToken(access_token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -369,6 +371,7 @@ describe('createAuthCode + consumeAuthCode', () => {
|
||||
userId: user.id,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
scopes: ['trips:read'],
|
||||
resource: null,
|
||||
codeChallenge: 'abc123',
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
@@ -379,7 +382,7 @@ describe('createAuthCode + consumeAuthCode', () => {
|
||||
expect(entry!.clientId).toBe(clientId);
|
||||
});
|
||||
|
||||
it('returns null for non-existent code', () => {
|
||||
it('returns null for non-existent code', async () => {
|
||||
expect(consumeAuthCode('does-not-exist')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -393,6 +396,7 @@ describe('createAuthCode + consumeAuthCode', () => {
|
||||
userId: user.id,
|
||||
redirectUri: 'https://example.com/callback',
|
||||
scopes: ['trips:read'],
|
||||
resource: null,
|
||||
codeChallenge: 'abc123',
|
||||
codeChallengeMethod: 'S256',
|
||||
});
|
||||
@@ -412,7 +416,7 @@ describe('issueTokens + getUserByAccessToken', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
const tokens = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const tokens = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
expect(tokens.access_token.startsWith('trekoa_')).toBe(true);
|
||||
expect(tokens.refresh_token.startsWith('trekrf_')).toBe(true);
|
||||
expect(tokens.token_type).toBe('Bearer');
|
||||
@@ -424,16 +428,16 @@ describe('issueTokens + getUserByAccessToken', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
const { access_token } = issueTokens(clientId, user.id, ['trips:read', 'budget:write']);
|
||||
const info = getUserByAccessToken(access_token);
|
||||
const { access_token } = await issueTokens(clientId, user.id, ['trips:read', 'budget:write']);
|
||||
const info = await getUserByAccessToken(access_token);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.user.email).toBe(user.email);
|
||||
expect(info!.scopes).toContain('trips:read');
|
||||
expect(info!.scopes).toContain('budget:write');
|
||||
});
|
||||
|
||||
it('getUserByAccessToken returns null for unknown token', () => {
|
||||
expect(getUserByAccessToken('trekoa_unknown')).toBeNull();
|
||||
it('getUserByAccessToken returns null for unknown token', async () => {
|
||||
expect(await getUserByAccessToken('trekoa_unknown')).toBeNull();
|
||||
});
|
||||
|
||||
it('getUserByAccessToken returns null for revoked token', async () => {
|
||||
@@ -441,9 +445,9 @@ describe('issueTokens + getUserByAccessToken', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
await revokeToken(access_token, clientId);
|
||||
expect(getUserByAccessToken(access_token)).toBeNull();
|
||||
expect(await getUserByAccessToken(access_token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -458,7 +462,7 @@ describe('refreshTokens', () => {
|
||||
const clientId = created.client!.client_id as string;
|
||||
const rawSecret = created.client!.client_secret as string;
|
||||
|
||||
const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const result = await refreshTokens(refresh_token, clientId, rawSecret);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.tokens).toBeDefined();
|
||||
@@ -471,9 +475,9 @@ describe('refreshTokens', () => {
|
||||
const clientId = created.client!.client_id as string;
|
||||
const rawSecret = created.client!.client_secret as string;
|
||||
|
||||
const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { access_token, refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
await refreshTokens(refresh_token, clientId, rawSecret);
|
||||
expect(getUserByAccessToken(access_token)).toBeNull();
|
||||
expect(await getUserByAccessToken(access_token)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not revoke the active MCP session on a normal (non-replayed) refresh (#1475)', async () => {
|
||||
@@ -482,7 +486,7 @@ describe('refreshTokens', () => {
|
||||
const clientId = created.client!.client_id as string;
|
||||
const rawSecret = created.client!.client_secret as string;
|
||||
|
||||
const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length;
|
||||
const result = await refreshTokens(refresh_token, clientId, rawSecret);
|
||||
expect(result.error).toBeUndefined();
|
||||
@@ -506,7 +510,7 @@ describe('refreshTokens', () => {
|
||||
const clientId = created.client!.client_id as string;
|
||||
const rawSecret = created.client!.client_secret as string;
|
||||
|
||||
const { access_token, refresh_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { access_token, refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
await revokeToken(access_token, clientId);
|
||||
const result = await refreshTokens(refresh_token, clientId, rawSecret);
|
||||
expect(result.error).toBe('invalid_grant');
|
||||
@@ -517,7 +521,7 @@ describe('refreshTokens', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
const { refresh_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const result = await refreshTokens(refresh_token, clientId, 'wrong-secret');
|
||||
expect(result.error).toBe('invalid_client');
|
||||
expect(result.status).toBe(401);
|
||||
@@ -534,11 +538,11 @@ describe('revokeToken', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
expect(getUserByAccessToken(access_token)).not.toBeNull();
|
||||
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
expect(await getUserByAccessToken(access_token)).not.toBeNull();
|
||||
|
||||
await revokeToken(access_token, clientId);
|
||||
expect(getUserByAccessToken(access_token)).toBeNull();
|
||||
expect(await getUserByAccessToken(access_token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -552,8 +556,8 @@ describe('listOAuthSessions + revokeSession', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
issueTokens(clientId, user.id, ['trips:read']);
|
||||
const sessions = listOAuthSessions(user.id);
|
||||
await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const sessions = await listOAuthSessions(user.id);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].client_id).toBe(clientId);
|
||||
});
|
||||
@@ -563,9 +567,9 @@ describe('listOAuthSessions + revokeSession', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
await revokeToken(access_token, clientId);
|
||||
const sessions = listOAuthSessions(user.id);
|
||||
const sessions = await listOAuthSessions(user.id);
|
||||
expect(sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -580,13 +584,13 @@ describe('listOAuthSessions + revokeSession', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
issueTokens(clientId, user.id, ['trips:read']);
|
||||
const sessions = listOAuthSessions(user.id);
|
||||
await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const sessions = await listOAuthSessions(user.id);
|
||||
const sessionId = sessions[0].id as number;
|
||||
|
||||
const result = await revokeSession(user.id, sessionId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(listOAuthSessions(user.id)).toHaveLength(0);
|
||||
expect(await listOAuthSessions(user.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -733,13 +737,13 @@ describe('validateAuthorizeRequest', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('verifyPKCE', () => {
|
||||
it('returns true for valid code_verifier / code_challenge pair (SHA256 base64url)', () => {
|
||||
it('returns true for valid code_verifier / code_challenge pair (SHA256 base64url)', async () => {
|
||||
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
expect(verifyPKCE(verifier, challenge)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for wrong verifier', () => {
|
||||
it('returns false for wrong verifier', async () => {
|
||||
const verifier = 'correct-verifier';
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
expect(verifyPKCE('wrong-verifier', challenge)).toBe(false);
|
||||
@@ -757,7 +761,7 @@ describe('authenticateClient', () => {
|
||||
const clientId = created.client!.client_id as string;
|
||||
const rawSecret = created.client!.client_secret as string;
|
||||
|
||||
const client = authenticateClient(clientId, rawSecret);
|
||||
const client = await authenticateClient(clientId, rawSecret);
|
||||
expect(client).not.toBeNull();
|
||||
expect(client!.client_id).toBe(clientId);
|
||||
});
|
||||
@@ -767,11 +771,11 @@ describe('authenticateClient', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
expect(authenticateClient(clientId, 'wrong-secret')).toBeNull();
|
||||
expect(await authenticateClient(clientId, 'wrong-secret')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown client_id', () => {
|
||||
expect(authenticateClient('unknown-client-id', 'any-secret')).toBeNull();
|
||||
it('returns null for unknown client_id', async () => {
|
||||
expect(await authenticateClient('unknown-client-id', 'any-secret')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -786,18 +790,18 @@ describe('saveConsent + getConsent + isConsentSufficient', () => {
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
await saveConsent(clientId, user.id, ['trips:read', 'budget:write']);
|
||||
const consent = getConsent(clientId, user.id);
|
||||
const consent = await getConsent(clientId, user.id);
|
||||
expect(consent).not.toBeNull();
|
||||
expect(consent).toContain('trips:read');
|
||||
expect(consent).toContain('budget:write');
|
||||
});
|
||||
|
||||
it('isConsentSufficient returns true when all requested scopes are in existing', () => {
|
||||
it('isConsentSufficient returns true when all requested scopes are in existing', async () => {
|
||||
expect(isConsentSufficient(['trips:read', 'budget:write'], ['trips:read'])).toBe(true);
|
||||
expect(isConsentSufficient(['trips:read', 'budget:write'], ['trips:read', 'budget:write'])).toBe(true);
|
||||
});
|
||||
|
||||
it('isConsentSufficient returns false when some scopes are missing', () => {
|
||||
it('isConsentSufficient returns false when some scopes are missing', async () => {
|
||||
expect(isConsentSufficient(['trips:read'], ['trips:read', 'budget:write'])).toBe(false);
|
||||
expect(isConsentSufficient([], ['trips:read'])).toBe(false);
|
||||
});
|
||||
@@ -816,7 +820,7 @@ describe('saveConsent — scope union (M5)', () => {
|
||||
await saveConsent(clientId, user.id, ['trips:read']);
|
||||
await saveConsent(clientId, user.id, ['budget:write']);
|
||||
|
||||
const consent = getConsent(clientId, user.id);
|
||||
const consent = await getConsent(clientId, user.id);
|
||||
expect(consent).toContain('trips:read');
|
||||
expect(consent).toContain('budget:write');
|
||||
});
|
||||
@@ -830,7 +834,7 @@ describe('saveConsent — scope union (M5)', () => {
|
||||
// approve only trips:read on a later request
|
||||
await saveConsent(clientId, user.id, ['trips:read']);
|
||||
|
||||
const consent = getConsent(clientId, user.id);
|
||||
const consent = await getConsent(clientId, user.id);
|
||||
// trips:write should NOT be removed (union semantics)
|
||||
expect(consent).toContain('trips:read');
|
||||
expect(consent).toContain('trips:write');
|
||||
@@ -845,9 +849,9 @@ describe('saveConsent — scope union (M5)', () => {
|
||||
await saveConsent(clientId, user.id, ['budget:write']);
|
||||
|
||||
// Should not require consent again for either scope
|
||||
expect(isConsentSufficient(getConsent(clientId, user.id)!, ['trips:read'])).toBe(true);
|
||||
expect(isConsentSufficient(getConsent(clientId, user.id)!, ['budget:write'])).toBe(true);
|
||||
expect(isConsentSufficient(getConsent(clientId, user.id)!, ['trips:read', 'budget:write'])).toBe(true);
|
||||
expect(isConsentSufficient((await getConsent(clientId, user.id))!, ['trips:read'])).toBe(true);
|
||||
expect(isConsentSufficient((await getConsent(clientId, user.id))!, ['budget:write'])).toBe(true);
|
||||
expect(isConsentSufficient((await getConsent(clientId, user.id))!, ['trips:read', 'budget:write'])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -861,8 +865,8 @@ describe('getUserByAccessToken — includes clientId (C2)', () => {
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = created.client!.client_id as string;
|
||||
|
||||
const { access_token } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const info = getUserByAccessToken(access_token);
|
||||
const { access_token } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const info = await getUserByAccessToken(access_token);
|
||||
expect(info).not.toBeNull();
|
||||
expect(info!.clientId).toBe(clientId);
|
||||
});
|
||||
@@ -891,7 +895,7 @@ describe('refreshTokens — replay detection (C3)', () => {
|
||||
const rawSecret = created.client!.client_secret as string;
|
||||
|
||||
// Issue tokens, then rotate once (old token becomes revoked)
|
||||
const { refresh_token: firstRefresh } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token: firstRefresh } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const rotateResult = await refreshTokens(firstRefresh, clientId, rawSecret);
|
||||
expect(rotateResult.error).toBeUndefined();
|
||||
const { refresh_token: secondRefresh } = rotateResult.tokens!;
|
||||
@@ -915,7 +919,7 @@ describe('refreshTokens — replay detection (C3)', () => {
|
||||
const rawSecret = created.client!.client_secret as string;
|
||||
|
||||
// Issue → rotate once
|
||||
const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token: first } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const r1 = await refreshTokens(first, clientId, rawSecret);
|
||||
const { access_token: access2, refresh_token: second } = r1.tokens!;
|
||||
|
||||
@@ -924,7 +928,7 @@ describe('refreshTokens — replay detection (C3)', () => {
|
||||
await refreshTokens(first, clientId, rawSecret);
|
||||
|
||||
// The rotated access token should also be dead now
|
||||
expect(getUserByAccessToken(access2)).toBeNull();
|
||||
expect(await getUserByAccessToken(access2)).toBeNull();
|
||||
|
||||
// The second refresh token should also be revoked
|
||||
const r2 = await refreshTokens(second, clientId, rawSecret);
|
||||
@@ -937,7 +941,7 @@ describe('refreshTokens — replay detection (C3)', () => {
|
||||
const clientId = created.client!.client_id as string;
|
||||
const rawSecret = created.client!.client_secret as string;
|
||||
|
||||
const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token: first } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
// Rotate once
|
||||
const r1 = await refreshTokens(first, clientId, rawSecret);
|
||||
const { refresh_token: second } = r1.tokens!;
|
||||
@@ -973,7 +977,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => {
|
||||
|
||||
it('OAUTH-GRACE-001: two clients refreshing the same token both get tokens', async () => {
|
||||
const { user, clientId, rawSecret } = await setup();
|
||||
const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
|
||||
const first = await refreshTokens(shared, clientId, rawSecret);
|
||||
const callsBefore = vi.mocked(revokeUserSessionsForClient).mock.calls.length;
|
||||
@@ -984,13 +988,13 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => {
|
||||
expect(second.tokens!.refresh_token).not.toBe(first.tokens!.refresh_token);
|
||||
// The whole point: no chain revocation, no torn-down MCP sessions, no login window.
|
||||
expect(vi.mocked(revokeUserSessionsForClient).mock.calls.length).toBe(callsBefore);
|
||||
expect(getUserByAccessToken(first.tokens!.access_token)).not.toBeNull();
|
||||
expect(getUserByAccessToken(second.tokens!.access_token)).not.toBeNull();
|
||||
expect(await getUserByAccessToken(first.tokens!.access_token)).not.toBeNull();
|
||||
expect(await getUserByAccessToken(second.tokens!.access_token)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('OAUTH-GRACE-002: both successors keep working afterwards', async () => {
|
||||
const { user, clientId, rawSecret } = await setup();
|
||||
const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const a = (await refreshTokens(shared, clientId, rawSecret)).tokens!;
|
||||
const b = (await refreshTokens(shared, clientId, rawSecret)).tokens!;
|
||||
|
||||
@@ -1000,7 +1004,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => {
|
||||
|
||||
it('OAUTH-GRACE-003: the same token replayed after the window is still theft', async () => {
|
||||
const { user, clientId, rawSecret } = await setup();
|
||||
const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const rotated = (await refreshTokens(shared, clientId, rawSecret)).tokens!;
|
||||
|
||||
agePastGrace(shared);
|
||||
@@ -1012,7 +1016,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => {
|
||||
|
||||
it('OAUTH-GRACE-004: a token revoked by logout is not re-opened by the window', async () => {
|
||||
const { user, clientId, rawSecret } = await setup();
|
||||
const { refresh_token: shared } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token: shared } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
|
||||
// Explicit revocation leaves no successor, so there is nothing to be
|
||||
// concurrent with — the grace must not resurrect it.
|
||||
@@ -1024,7 +1028,7 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => {
|
||||
|
||||
it('OAUTH-GRACE-005: a chain killed by a real replay stays dead inside the window', async () => {
|
||||
const { user, clientId, rawSecret } = await setup();
|
||||
const { refresh_token: first } = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const { refresh_token: first } = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
const second = (await refreshTokens(first, clientId, rawSecret)).tokens!;
|
||||
|
||||
// Real theft: the first token turns up again once the window has passed.
|
||||
@@ -1042,24 +1046,24 @@ describe('refreshTokens — concurrent rotation grace (#1007)', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('verifyPKCE — format validation (H1)', () => {
|
||||
it('returns false for a code_verifier that is too short (< 43 chars)', () => {
|
||||
it('returns false for a code_verifier that is too short (< 43 chars)', async () => {
|
||||
const { challenge } = makePkce();
|
||||
expect(verifyPKCE('short', challenge)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a code_verifier that is too long (> 128 chars)', () => {
|
||||
it('returns false for a code_verifier that is too long (> 128 chars)', async () => {
|
||||
const { challenge } = makePkce();
|
||||
const longVerifier = 'a'.repeat(129);
|
||||
expect(verifyPKCE(longVerifier, challenge)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a code_verifier with invalid characters', () => {
|
||||
it('returns false for a code_verifier with invalid characters', async () => {
|
||||
const { challenge } = makePkce();
|
||||
const badVerifier = 'A'.repeat(42) + ' '; // space is not allowed
|
||||
expect(verifyPKCE(badVerifier, challenge)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a valid 43-char verifier matching its challenge', () => {
|
||||
it('returns true for a valid 43-char verifier matching its challenge', async () => {
|
||||
const { verifier, challenge } = makePkce();
|
||||
expect(verifyPKCE(verifier, challenge)).toBe(true);
|
||||
});
|
||||
@@ -1145,7 +1149,7 @@ describe('addon gate and MCP endpoint', () => {
|
||||
expect(await svc.mcpEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('mcpSafeUrl forwards to the app-config helper', () => {
|
||||
it('mcpSafeUrl forwards to the app-config helper', async () => {
|
||||
expect(svc.mcpSafeUrl()).toBe(getMcpSafeUrl());
|
||||
});
|
||||
});
|
||||
@@ -1161,7 +1165,7 @@ describe('pending-code store', () => {
|
||||
codeChallengeMethod: 'S256' as const,
|
||||
};
|
||||
|
||||
it('refuses a new code at capacity and the sweep frees it again', () => {
|
||||
it('refuses a new code at capacity and the sweep frees it again', async () => {
|
||||
for (let i = 0; i < MAX_PENDING_CODES; i++) createAuthCode(codeParams);
|
||||
|
||||
expect(createAuthCode(codeParams)).toBeNull();
|
||||
@@ -1174,7 +1178,7 @@ describe('pending-code store', () => {
|
||||
sweepPendingCodes(Date.now() + 3 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('a code past its TTL is consumed as invalid', () => {
|
||||
it('a code past its TTL is consumed as invalid', async () => {
|
||||
const code = createAuthCode(codeParams)!;
|
||||
const realNow = Date.now;
|
||||
Date.now = () => realNow() + 3 * 60 * 1000;
|
||||
@@ -1191,10 +1195,10 @@ describe('branches the legacy suite could not reach', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = await makeClient(user.id);
|
||||
const clientId = (created.client as { client_id: string }).client_id;
|
||||
const tokens = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const tokens = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
testDb.prepare("UPDATE oauth_tokens SET access_token_expires_at = '2000-01-01T00:00:00.000Z'").run();
|
||||
|
||||
expect(getUserByAccessToken(tokens.access_token)).toBeNull();
|
||||
expect(await getUserByAccessToken(tokens.access_token)).toBeNull();
|
||||
});
|
||||
|
||||
it('refreshTokens rejects an unknown client before touching the token', async () => {
|
||||
@@ -1205,7 +1209,7 @@ describe('branches the legacy suite could not reach', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const created = await createOAuthClient(user.id, 'Public', ['https://example.com/callback'], ['trips:read'], null, { isPublic: true });
|
||||
const clientId = (created.client as { client_id: string }).client_id;
|
||||
const tokens = issueTokens(clientId, user.id, ['trips:read']);
|
||||
const tokens = await issueTokens(clientId, user.id, ['trips:read']);
|
||||
|
||||
const result = await refreshTokens(tokens.refresh_token, clientId, undefined);
|
||||
|
||||
@@ -1220,8 +1224,8 @@ describe('branches the legacy suite could not reach', () => {
|
||||
const conf = await makeClient(user.id, { name: 'Confidential' });
|
||||
const confId = (conf.client as { client_id: string }).client_id;
|
||||
|
||||
expect(authenticateClient(pubId, undefined)?.client_id).toBe(pubId);
|
||||
expect(authenticateClient(confId, undefined)).toBeNull();
|
||||
expect((await authenticateClient(pubId, undefined))?.client_id).toBe(pubId);
|
||||
expect(await authenticateClient(confId, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('validateAuthorizeRequest rejects a resource that is not the MCP endpoint', async () => {
|
||||
@@ -1266,7 +1270,7 @@ describe('branches the legacy suite could not reach', () => {
|
||||
});
|
||||
|
||||
describe('module-scoped OAuth state', () => {
|
||||
it('shares the pending-code map across service instances', () => {
|
||||
it('shares the pending-code map across service instances', async () => {
|
||||
// The load-bearing invariant: the consent controller writes the code through
|
||||
// the DI singleton, the SDK exchange path reads it back. The map is module-
|
||||
// scoped, so even a second hand-built instance must see it — two maps would
|
||||
@@ -1311,7 +1315,7 @@ describe('OauthModule', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('admin OAuth sessions', () => {
|
||||
it('ADMIN-SVC-074 — listAllOAuthSessions survives a row with malformed scopes JSON', () => {
|
||||
it('ADMIN-SVC-074 — listAllOAuthSessions survives a row with malformed scopes JSON', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO oauth_clients (client_id, client_secret_hash, name) VALUES ('c1', 'hash', 'Client')").run();
|
||||
testDb.prepare(`
|
||||
@@ -1320,7 +1324,7 @@ describe('admin OAuth sessions', () => {
|
||||
VALUES ('c1', ?, 'ahash', 'rhash', 'not-json{', datetime('now', '+1 hour'), datetime('now', '+1 day'))
|
||||
`).run(user.id);
|
||||
|
||||
const sessions = svc.listAllOAuthSessions() as any[];
|
||||
const sessions = (await svc.listAllOAuthSessions()) as any[];
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].scopes).toBeNull();
|
||||
});
|
||||
|
||||
@@ -19,46 +19,46 @@ const BODY = {
|
||||
|
||||
describe('PlaceShadowController', () => {
|
||||
describe('POST /api/place-shadow/pick', () => {
|
||||
it('answers 200 { recorded: false } when the log is off, never an error', () => {
|
||||
const record = vi.fn().mockReturnValue(false);
|
||||
expect(makeController({ record }).pick(BODY)).toEqual({ recorded: false });
|
||||
it('answers 200 { recorded: false } when the log is off, never an error', async () => {
|
||||
const record = vi.fn().mockResolvedValue(false);
|
||||
expect(await makeController({ record }).pick(BODY)).toEqual({ recorded: false });
|
||||
expect(record).toHaveBeenCalledWith(BODY);
|
||||
});
|
||||
|
||||
it('reports a written row', () => {
|
||||
expect(makeController({ record: vi.fn().mockReturnValue(true) }).pick(BODY)).toEqual({ recorded: true });
|
||||
it('reports a written row', async () => {
|
||||
expect(await makeController({ record: vi.fn().mockResolvedValue(true) }).pick(BODY)).toEqual({ recorded: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/place-shadow/export', () => {
|
||||
const page = { version: 1 as const, generatedAt: 'now', rows: [], nextAfter: null };
|
||||
|
||||
it('passes a positive cursor through', () => {
|
||||
const exp = vi.fn().mockReturnValue(page);
|
||||
makeController({ export: exp }).export('42');
|
||||
it('passes a positive cursor through', async () => {
|
||||
const exp = vi.fn().mockResolvedValue(page);
|
||||
await makeController({ export: exp }).export('42');
|
||||
expect(exp).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('starts from the beginning for anything that is not a positive integer', () => {
|
||||
const exp = vi.fn().mockReturnValue(page);
|
||||
it('starts from the beginning for anything that is not a positive integer', async () => {
|
||||
const exp = vi.fn().mockResolvedValue(page);
|
||||
const c = makeController({ export: exp });
|
||||
for (const bad of [undefined, '', 'abc', '0', '-5', '1.5', '1e400']) {
|
||||
c.export(bad);
|
||||
await c.export(bad);
|
||||
}
|
||||
expect(exp.mock.calls.every(([arg]) => arg === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/place-shadow', () => {
|
||||
it('reports how many rows went', () => {
|
||||
expect(makeController({ clear: vi.fn().mockReturnValue(17) }).clear()).toEqual({ removed: 17 });
|
||||
it('reports how many rows went', async () => {
|
||||
expect(await makeController({ clear: vi.fn().mockResolvedValue(17) }).clear()).toEqual({ removed: 17 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/place-shadow/summary', () => {
|
||||
it('hands the service summary straight back', () => {
|
||||
it('hands the service summary straight back', async () => {
|
||||
const summary = { enabled: true, total: 3 };
|
||||
expect(makeController({ summary: vi.fn().mockReturnValue(summary) }).summary()).toBe(summary);
|
||||
expect(await makeController({ summary: vi.fn().mockResolvedValue(summary) }).summary()).toBe(summary);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,40 +25,40 @@ function makeJob(shadow: Partial<PlaceShadowService>, registrar = registrarStub(
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('PlaceShadowRetentionJob', () => {
|
||||
it('registers at 3:40, clear of the 3:00 idempotency purge', () => {
|
||||
const { job, registrar } = makeJob({ purgeExpired: vi.fn(() => 0) });
|
||||
job.onApplicationBootstrap();
|
||||
it('registers at 3:40, clear of the 3:00 idempotency purge', async () => {
|
||||
const { job, registrar } = makeJob({ purgeExpired: vi.fn(async () => 0) });
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('place-shadow-retention', '40 3 * * *', expect.any(Function));
|
||||
});
|
||||
|
||||
it('does not register under the test gate', () => {
|
||||
it('does not register under the test gate', async () => {
|
||||
const { job, registrar } = makeJob({ purgeExpired: vi.fn() }, registrarStub(false));
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs only when it actually removed something', () => {
|
||||
const { job } = makeJob({ purgeExpired: vi.fn(() => 0) });
|
||||
job.tick();
|
||||
it('logs only when it actually removed something', async () => {
|
||||
const { job } = makeJob({ purgeExpired: vi.fn(async () => 0) });
|
||||
await job.tick();
|
||||
expect(logMock.logInfo).not.toHaveBeenCalled();
|
||||
|
||||
const loud = makeJob({ purgeExpired: vi.fn(() => 12) });
|
||||
loud.job.tick();
|
||||
const loud = makeJob({ purgeExpired: vi.fn(async () => 12) });
|
||||
await loud.job.tick();
|
||||
expect(logMock.logInfo).toHaveBeenCalledWith(expect.stringContaining('12'));
|
||||
});
|
||||
|
||||
it('contains a failing purge instead of letting it escape into the scheduler', () => {
|
||||
it('contains a failing purge instead of letting it escape into the scheduler', async () => {
|
||||
const { job } = makeJob({ purgeExpired: vi.fn(() => { throw new Error('database is locked'); }) });
|
||||
expect(() => job.tick()).not.toThrow();
|
||||
await expect(job.tick()).resolves.toBeUndefined();
|
||||
expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('database is locked'));
|
||||
});
|
||||
|
||||
it('runs the purge even while the log itself is switched off', () => {
|
||||
it('runs the purge even while the log itself is switched off', async () => {
|
||||
// Switching collection off should let what it already gathered age out, so
|
||||
// the job must not consult `enabled()` before purging.
|
||||
const enabled = vi.fn(() => false);
|
||||
const purgeExpired = vi.fn(() => 3);
|
||||
makeJob({ enabled, purgeExpired }).job.tick();
|
||||
const enabled = vi.fn(async () => false);
|
||||
const purgeExpired = vi.fn(async () => 3);
|
||||
await makeJob({ enabled, purgeExpired }).job.tick();
|
||||
expect(purgeExpired).toHaveBeenCalled();
|
||||
expect(enabled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ function enable(on = true) {
|
||||
.run('place_shadow_enabled', on ? 'true' : 'false');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
conn = new Database(':memory:');
|
||||
conn.exec(SCHEMA);
|
||||
svc = new PlaceShadowService(dbFacade(conn));
|
||||
@@ -71,32 +71,32 @@ beforeEach(() => {
|
||||
|
||||
describe('PlaceShadowService', () => {
|
||||
describe('the switch', () => {
|
||||
it('is off when the setting row is absent', () => {
|
||||
expect(svc.enabled()).toBe(false);
|
||||
expect(svc.record(PICK)).toBe(false);
|
||||
it('is off when the setting row is absent', async () => {
|
||||
expect(await svc.enabled()).toBe(false);
|
||||
expect(await svc.record(PICK)).toBe(false);
|
||||
expect(conn.prepare('SELECT COUNT(*) AS n FROM place_shadow_picks').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('is off for any value that is not exactly "true"', () => {
|
||||
it('is off for any value that is not exactly "true"', async () => {
|
||||
for (const value of ['false', '1', 'yes', 'TRUE', '']) {
|
||||
conn.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('place_shadow_enabled', value);
|
||||
expect(svc.enabled(), `value ${JSON.stringify(value)}`).toBe(false);
|
||||
expect(await svc.enabled(), `value ${JSON.stringify(value)}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('writes once switched on', () => {
|
||||
it('writes once switched on', async () => {
|
||||
enable();
|
||||
expect(svc.record(PICK)).toBe(true);
|
||||
expect(svc.summary().total).toBe(1);
|
||||
expect(await svc.record(PICK)).toBe(true);
|
||||
expect((await svc.summary()).total).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('record', () => {
|
||||
beforeEach(() => enable());
|
||||
|
||||
it('rounds every coordinate to three decimals', () => {
|
||||
svc.record({ ...PICK, pickedLat: 54.0891234, pickedLng: 12.1372987, biasLat: 54.08871, biasLng: 12.14049 });
|
||||
it('rounds every coordinate to three decimals', async () => {
|
||||
await svc.record({ ...PICK, pickedLat: 54.0891234, pickedLng: 12.1372987, biasLat: 54.08871, biasLng: 12.14049 });
|
||||
const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, number>;
|
||||
expect(row.picked_lat).toBe(54.089);
|
||||
expect(row.picked_lng).toBe(12.137);
|
||||
@@ -104,34 +104,36 @@ describe('PlaceShadowService', () => {
|
||||
expect(row.bias_lng).toBe(12.14);
|
||||
});
|
||||
|
||||
it('stores an absent bias and place id as NULL rather than inventing zeroes', () => {
|
||||
svc.record({ ...PICK, biasLat: undefined, biasLng: undefined, pickedPlaceId: undefined });
|
||||
it('stores an absent bias and place id as NULL rather than inventing zeroes', async () => {
|
||||
await svc.record({ ...PICK, biasLat: undefined, biasLng: undefined, pickedPlaceId: undefined });
|
||||
const row = conn.prepare('SELECT * FROM place_shadow_picks').get() as Record<string, unknown>;
|
||||
expect(row.bias_lat).toBeNull();
|
||||
expect(row.bias_lng).toBeNull();
|
||||
expect(row.picked_place_id).toBeNull();
|
||||
});
|
||||
|
||||
it('drops a row whose rank is outside the list it claims to come from', () => {
|
||||
expect(svc.record({ ...PICK, liveRank: 8, liveCount: 8 })).toBe(false);
|
||||
expect(svc.record({ ...PICK, liveRank: 99, liveCount: 3 })).toBe(false);
|
||||
expect(svc.summary().total).toBe(0);
|
||||
it('drops a row whose rank is outside the list it claims to come from', async () => {
|
||||
expect(await svc.record({ ...PICK, liveRank: 8, liveCount: 8 })).toBe(false);
|
||||
expect(await svc.record({ ...PICK, liveRank: 99, liveCount: 3 })).toBe(false);
|
||||
expect((await svc.summary()).total).toBe(0);
|
||||
// The boundary case one below is a legitimate last-result pick.
|
||||
expect(svc.record({ ...PICK, liveRank: 7, liveCount: 8 })).toBe(true);
|
||||
expect(await svc.record({ ...PICK, liveRank: 7, liveCount: 8 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summary', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
enable();
|
||||
const ranks = [0, 0, 0, 1, 4, 5, 9, 10, 42];
|
||||
ranks.forEach((rank, i) =>
|
||||
svc.record({ ...PICK, liveRank: rank, liveCount: 50, source: i % 2 ? 'search:nominatim' : 'autocomplete:google' }),
|
||||
);
|
||||
// Sequential, not Promise.all: the rows are inserted in rank order and the
|
||||
// export/paging assertions below read that order back.
|
||||
for (const [i, rank] of ranks.entries()) {
|
||||
await svc.record({ ...PICK, liveRank: rank, liveCount: 50, source: i % 2 ? 'search:nominatim' : 'autocomplete:google' });
|
||||
}
|
||||
});
|
||||
|
||||
it('buckets the live rank instead of averaging it', () => {
|
||||
expect(svc.summary().liveRankBuckets).toEqual([
|
||||
it('buckets the live rank instead of averaging it', async () => {
|
||||
expect((await svc.summary()).liveRankBuckets).toEqual([
|
||||
{ bucket: '1', count: 3 },
|
||||
{ bucket: '2-5', count: 2 },
|
||||
{ bucket: '6-10', count: 2 },
|
||||
@@ -139,56 +141,56 @@ describe('PlaceShadowService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports the top-one and top-five shares over the whole corpus', () => {
|
||||
const s = svc.summary();
|
||||
it('reports the top-one and top-five shares over the whole corpus', async () => {
|
||||
const s = await svc.summary();
|
||||
expect(s.total).toBe(9);
|
||||
expect(s.liveTopOneShare).toBeCloseTo(3 / 9, 6);
|
||||
expect(s.liveTopFiveShare).toBeCloseTo(5 / 9, 6);
|
||||
});
|
||||
|
||||
it('counts by source, busiest first', () => {
|
||||
expect(svc.summary().bySource).toEqual([
|
||||
it('counts by source, busiest first', async () => {
|
||||
expect((await svc.summary()).bySource).toEqual([
|
||||
{ source: 'autocomplete:google', count: 5 },
|
||||
{ source: 'search:nominatim', count: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers on an empty corpus without dividing by zero', () => {
|
||||
it('answers on an empty corpus without dividing by zero', async () => {
|
||||
conn.exec('DELETE FROM place_shadow_picks');
|
||||
const s = svc.summary();
|
||||
const s = await svc.summary();
|
||||
expect(s).toMatchObject({ total: 0, liveTopOneShare: 0, liveTopFiveShare: 0, oldest: null, newest: null });
|
||||
expect(s.retentionDays).toBe(RETENTION_DAYS);
|
||||
});
|
||||
|
||||
it('reports the switch state it was asked about', () => {
|
||||
expect(svc.summary().enabled).toBe(true);
|
||||
it('reports the switch state it was asked about', async () => {
|
||||
expect((await svc.summary()).enabled).toBe(true);
|
||||
enable(false);
|
||||
expect(svc.summary().enabled).toBe(false);
|
||||
expect((await svc.summary()).enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
enable();
|
||||
for (let i = 0; i < 7; i++) svc.record({ ...PICK, query: `q${i}` });
|
||||
for (let i = 0; i < 7; i++) await svc.record({ ...PICK, query: `q${i}` });
|
||||
});
|
||||
|
||||
it('pages by id and hands back the cursor for the next page', () => {
|
||||
const first = svc.export(undefined, 3);
|
||||
it('pages by id and hands back the cursor for the next page', async () => {
|
||||
const first = await svc.export(undefined, 3);
|
||||
expect(first.rows.map(r => r.query)).toEqual(['q0', 'q1', 'q2']);
|
||||
expect(first.nextAfter).toBe(first.rows[2].id);
|
||||
|
||||
const second = svc.export(first.nextAfter ?? undefined, 3);
|
||||
const second = await svc.export(first.nextAfter ?? undefined, 3);
|
||||
expect(second.rows.map(r => r.query)).toEqual(['q3', 'q4', 'q5']);
|
||||
|
||||
const third = svc.export(second.nextAfter ?? undefined, 3);
|
||||
const third = await svc.export(second.nextAfter ?? undefined, 3);
|
||||
expect(third.rows.map(r => r.query)).toEqual(['q6']);
|
||||
// Nothing beyond, so no cursor — that is how a reader knows to stop.
|
||||
expect(third.nextAfter).toBeNull();
|
||||
});
|
||||
|
||||
it('maps snake_case columns onto the camelCase contract', () => {
|
||||
const row = svc.export(undefined, 1).rows[0];
|
||||
it('maps snake_case columns onto the camelCase contract', async () => {
|
||||
const row = (await svc.export(undefined, 1)).rows[0];
|
||||
expect(row).toMatchObject({
|
||||
query: 'q0',
|
||||
lang: 'de',
|
||||
@@ -201,41 +203,41 @@ describe('PlaceShadowService', () => {
|
||||
expect(typeof row.createdAt).toBe('string');
|
||||
});
|
||||
|
||||
it('stamps the version so an evaluator can refuse a dump it does not know', () => {
|
||||
expect(svc.export().version).toBe(1);
|
||||
it('stamps the version so an evaluator can refuse a dump it does not know', async () => {
|
||||
expect((await svc.export()).version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retention and wipe', () => {
|
||||
beforeEach(() => enable());
|
||||
|
||||
it('removes only rows past the window', () => {
|
||||
svc.record(PICK);
|
||||
svc.record(PICK);
|
||||
it('removes only rows past the window', async () => {
|
||||
await svc.record(PICK);
|
||||
await svc.record(PICK);
|
||||
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days') WHERE id = 1").run();
|
||||
expect(svc.purgeExpired()).toBe(1);
|
||||
expect(svc.summary().total).toBe(1);
|
||||
expect(await svc.purgeExpired()).toBe(1);
|
||||
expect((await svc.summary()).total).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a row that is one day short of the window', () => {
|
||||
svc.record(PICK);
|
||||
it('keeps a row that is one day short of the window', async () => {
|
||||
await svc.record(PICK);
|
||||
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', ?)")
|
||||
.run(`-${RETENTION_DAYS - 1} days`);
|
||||
expect(svc.purgeExpired()).toBe(0);
|
||||
expect(await svc.purgeExpired()).toBe(0);
|
||||
});
|
||||
|
||||
it('runs even while the log is switched off, so old rows still age out', () => {
|
||||
svc.record(PICK);
|
||||
it('runs even while the log is switched off, so old rows still age out', async () => {
|
||||
await svc.record(PICK);
|
||||
conn.prepare("UPDATE place_shadow_picks SET created_at = datetime('now', '-200 days')").run();
|
||||
enable(false);
|
||||
expect(svc.purgeExpired()).toBe(1);
|
||||
expect(await svc.purgeExpired()).toBe(1);
|
||||
});
|
||||
|
||||
it('clear() empties the table and reports how many went', () => {
|
||||
svc.record(PICK);
|
||||
svc.record(PICK);
|
||||
expect(svc.clear()).toBe(2);
|
||||
expect(svc.summary().total).toBe(0);
|
||||
it('clear() empties the table and reports how many went', async () => {
|
||||
await svc.record(PICK);
|
||||
await svc.record(PICK);
|
||||
expect(await svc.clear()).toBe(2);
|
||||
expect((await svc.summary()).total).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { RouteUsageService, RETENTION_DAYS } from '../../../src/nest/route-usage/route-usage.service';
|
||||
import type { RouteUsageEntry } from '@trek/shared';
|
||||
import { createTestUnitOfWork } from '../../helpers/test-uow';
|
||||
|
||||
/**
|
||||
* SRV-ROUTEUSAGE-001..010 — the counters behind "could TREK host a router".
|
||||
@@ -27,14 +28,15 @@ function makeDb(): Database.Database {
|
||||
}
|
||||
|
||||
/** The slice of DatabaseService this domain uses. */
|
||||
function serviceOver(db: Database.Database): RouteUsageService {
|
||||
async function serviceOver(db: Database.Database): Promise<RouteUsageService> {
|
||||
const bridge = {
|
||||
get: <T>(sql: string, ...p: unknown[]) => db.prepare(sql).get(...p) as T | undefined,
|
||||
all: <T>(sql: string, ...p: unknown[]) => db.prepare(sql).all(...p) as T[],
|
||||
run: (sql: string, ...p: unknown[]) => db.prepare(sql).run(...p),
|
||||
transaction: <T>(fn: () => T) => db.transaction(fn)(),
|
||||
};
|
||||
return new RouteUsageService(bridge as never);
|
||||
// The batch transaction runs through MikroORM on this very handle now, so the
|
||||
// raw statements inside the callback still land inside it.
|
||||
return new RouteUsageService(bridge as never, await createTestUnitOfWork(db));
|
||||
}
|
||||
|
||||
const entry = (over: Partial<RouteUsageEntry> = {}): RouteUsageEntry => ({
|
||||
@@ -46,44 +48,44 @@ describe('RouteUsageService', () => {
|
||||
let db: Database.Database;
|
||||
let svc: RouteUsageService;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
db = makeDb();
|
||||
svc = serviceOver(db);
|
||||
svc = await serviceOver(db);
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-001: counting is on unless an operator turned it off', () => {
|
||||
expect(svc.enabled()).toBe(true);
|
||||
it('SRV-ROUTEUSAGE-001: counting is on unless an operator turned it off', async () => {
|
||||
expect(await svc.enabled()).toBe(true);
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run();
|
||||
expect(svc.enabled()).toBe(false);
|
||||
expect(await svc.enabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-002: a switched-off instance records nothing and says so', () => {
|
||||
it('SRV-ROUTEUSAGE-002: a switched-off instance records nothing and says so', async () => {
|
||||
db.prepare("INSERT INTO app_settings (key, value) VALUES ('route_usage_enabled', 'false')").run();
|
||||
expect(svc.record({ entries: [entry()] })).toBe(false);
|
||||
expect(await svc.record({ entries: [entry()] })).toBe(false);
|
||||
expect(db.prepare('SELECT COUNT(*) c FROM route_usage_daily').get()).toEqual({ c: 0 });
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-003: a batch adds onto one row per day, profile, kind and engine', () => {
|
||||
svc.record({ entries: [entry({ requests: 5, waypoints: 12, km: 300, failed: 1 })] });
|
||||
svc.record({ entries: [entry({ requests: 3, waypoints: 6, km: 150, failed: 0 })] });
|
||||
it('SRV-ROUTEUSAGE-003: a batch adds onto one row per day, profile, kind and engine', async () => {
|
||||
await svc.record({ entries: [entry({ requests: 5, waypoints: 12, km: 300, failed: 1 })] });
|
||||
await svc.record({ entries: [entry({ requests: 3, waypoints: 6, km: 150, failed: 0 })] });
|
||||
|
||||
const rows = svc.rows();
|
||||
const rows = await svc.rows();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ requests: 8, waypoints: 18, km: 450, failed: 1 });
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-004: a different kind or engine is its own row', () => {
|
||||
svc.record({ entries: [
|
||||
it('SRV-ROUTEUSAGE-004: a different kind or engine is its own row', async () => {
|
||||
await svc.record({ entries: [
|
||||
entry({ surface: 'legs' }),
|
||||
entry({ surface: 'alternatives' }),
|
||||
entry({ surface: 'legs', selfHosted: true }),
|
||||
entry({ surface: 'legs', profile: 'walking' }),
|
||||
] });
|
||||
expect(svc.rows()).toHaveLength(4);
|
||||
expect(await svc.rows()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-005: an empty corpus answers with zeroes rather than nothing', () => {
|
||||
const s = svc.summary();
|
||||
it('SRV-ROUTEUSAGE-005: an empty corpus answers with zeroes rather than nothing', async () => {
|
||||
const s = await svc.summary();
|
||||
expect(s.totalRequests).toBe(0);
|
||||
expect(s.daysCovered).toBe(0);
|
||||
expect(s.busiestDay).toBeNull();
|
||||
@@ -91,12 +93,12 @@ describe('RouteUsageService', () => {
|
||||
expect(s.retentionDays).toBe(RETENTION_DAYS);
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-006: the summary carries the per-day average and the busiest day', () => {
|
||||
it('SRV-ROUTEUSAGE-006: the summary carries the per-day average and the busiest day', async () => {
|
||||
// Two days by hand: the service always writes "today", so the spread is set here.
|
||||
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,10,30,900,0)`).run();
|
||||
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-02','driving','legs',0,30,60,1800,2)`).run();
|
||||
|
||||
const s = svc.summary();
|
||||
const s = await svc.summary();
|
||||
expect(s.totalRequests).toBe(40);
|
||||
expect(s.totalFailed).toBe(2);
|
||||
expect(s.daysCovered).toBe(2);
|
||||
@@ -107,30 +109,30 @@ describe('RouteUsageService', () => {
|
||||
expect(s.lastDay).toBe('2026-09-02');
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-007: mean waypoints and kilometres are per request, not per row', () => {
|
||||
it('SRV-ROUTEUSAGE-007: mean waypoints and kilometres are per request, not per row', async () => {
|
||||
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,4,20,400,0)`).run();
|
||||
const s = svc.summary();
|
||||
const s = await svc.summary();
|
||||
expect(s.waypointsPerRequest).toBe(5);
|
||||
expect(s.kmPerRequest).toBe(100);
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-008: the self-hosted share is the part already off the public hosts', () => {
|
||||
it('SRV-ROUTEUSAGE-008: the self-hosted share is the part already off the public hosts', async () => {
|
||||
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',0,30,0,0,0)`).run();
|
||||
db.prepare(`INSERT INTO route_usage_daily VALUES ('2026-09-01','driving','legs',1,10,0,0,0)`).run();
|
||||
expect(svc.summary().selfHostedShare).toBe(0.25);
|
||||
expect((await svc.summary()).selfHostedShare).toBe(0.25);
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-009: retention drops days past the window and keeps the rest', () => {
|
||||
it('SRV-ROUTEUSAGE-009: retention drops days past the window and keeps the rest', async () => {
|
||||
db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-1 day'),'driving','legs',0,1,0,0,0)`).run();
|
||||
db.prepare(`INSERT INTO route_usage_daily VALUES (date('now','-${RETENTION_DAYS + 5} days'),'driving','legs',0,1,0,0,0)`).run();
|
||||
|
||||
expect(svc.purgeExpired()).toBe(1);
|
||||
expect(svc.rows()).toHaveLength(1);
|
||||
expect(await svc.purgeExpired()).toBe(1);
|
||||
expect(await svc.rows()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('SRV-ROUTEUSAGE-010: clearing wipes every counter', () => {
|
||||
svc.record({ entries: [entry(), entry({ surface: 'route' })] });
|
||||
expect(svc.clear()).toBe(2);
|
||||
expect(svc.rows()).toHaveLength(0);
|
||||
it('SRV-ROUTEUSAGE-010: clearing wipes every counter', async () => {
|
||||
await svc.record({ entries: [entry(), entry({ surface: 'route' })] });
|
||||
expect(await svc.clear()).toBe(2);
|
||||
expect(await svc.rows()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ const FRESH_STATE = {
|
||||
function makeController(over: Partial<Record<keyof StorageAdminService, unknown>> = {}) {
|
||||
const writeAudit = vi.fn();
|
||||
const service = {
|
||||
state: vi.fn(() => FRESH_STATE),
|
||||
state: vi.fn(async () => FRESH_STATE),
|
||||
applyConfig: vi.fn(),
|
||||
...over,
|
||||
} as unknown as StorageAdminService;
|
||||
@@ -61,9 +61,9 @@ const CONFIG = {
|
||||
} as StorageConfigDto;
|
||||
|
||||
describe('StorageAdminController', () => {
|
||||
it('STORCTL-001 GET returns the service state untouched', () => {
|
||||
it('STORCTL-001 GET returns the service state untouched', async () => {
|
||||
const { controller, service } = makeController();
|
||||
expect(controller.get()).toBe(FRESH_STATE);
|
||||
expect(await controller.get()).toBe(FRESH_STATE);
|
||||
expect(service.state).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { StorageStatsService } from '../../../../src/nest/storage/storage-stats.
|
||||
import { StorageRegistryService, BACKENDS_KEY, CATEGORIES_KEY } from '../../../../src/nest/storage/storage-registry.service';
|
||||
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
||||
import { StorageConflictError } from '../../../../src/nest/storage/storage.types';
|
||||
import { createTestUnitOfWork } from '../../../helpers/test-uow';
|
||||
|
||||
const db = new DatabaseService(testDb);
|
||||
|
||||
@@ -61,17 +62,18 @@ const S3_OPTIONS = {
|
||||
};
|
||||
|
||||
/** Real registry + real service over the in-memory DB. */
|
||||
function makeService(opts: { uploadsRoot?: string } = {}) {
|
||||
async function makeService(opts: { uploadsRoot?: string } = {}) {
|
||||
const uploadsRoot = opts.uploadsRoot ?? makeTmpDir();
|
||||
setSetting(BACKENDS_KEY, JSON.stringify([{ name: 'uploads-local', type: 'local', options: { root: uploadsRoot } }]));
|
||||
const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService;
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService());
|
||||
registry.onModuleInit();
|
||||
const uow = await createTestUnitOfWork(testDb);
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService(), uow);
|
||||
await registry.onModuleInit();
|
||||
const storage = new StorageService(registry);
|
||||
const jobs = new StorageJobsService(registry);
|
||||
const stats = new StorageStatsService(storage, db);
|
||||
const service = new StorageAdminService(db, registry, storage, jobs, stats);
|
||||
return { service, registry, uploadsRoot, stats, jobs };
|
||||
const service = new StorageAdminService(db, registry, storage, jobs, stats, uow);
|
||||
return { service, registry, uploadsRoot, stats, jobs, uow };
|
||||
}
|
||||
|
||||
/** The settings-owned document the service persists (uploads override + extras). */
|
||||
@@ -90,8 +92,8 @@ function configWith(uploadsRoot: string, extra: Partial<StorageConfig> = {}): St
|
||||
* audit #7) — most tests just want "the current one", read fresh at call
|
||||
* time so a second call in the same test picks up the bump from the first.
|
||||
*/
|
||||
function put(service: StorageAdminService, config: StorageConfig): void {
|
||||
service.applyConfig({ ...config, version: service.state().version });
|
||||
async function put(service: StorageAdminService, config: StorageConfig): Promise<void> {
|
||||
await service.applyConfig({ ...config, version: (await (await service.state())).version });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -103,9 +105,9 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('StorageAdminService.state', () => {
|
||||
it('STORADM-001 renders the effective world: sources, categories-per-backend, flags', () => {
|
||||
const { service, uploadsRoot } = makeService();
|
||||
const state = service.state();
|
||||
it('STORADM-001 renders the effective world: sources, categories-per-backend, flags', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
const state = (await service.state());
|
||||
const uploads = state.backends.find((b) => b.name === 'uploads-local')!;
|
||||
expect(uploads).toMatchObject({ type: 'local', source: 'settings', options: { root: uploadsRoot } });
|
||||
expect(uploads.categories).toContain('files');
|
||||
@@ -116,51 +118,51 @@ describe('StorageAdminService.state', () => {
|
||||
expect(state.health).toEqual({ replicaFailures: [] });
|
||||
});
|
||||
|
||||
it('STORADM-002 masks exactly the secret fields (accessKeyId stays visible)', () => {
|
||||
const { service, uploadsRoot } = makeService();
|
||||
put(service, configWith(uploadsRoot, {
|
||||
it('STORADM-002 masks exactly the secret fields (accessKeyId stays visible)', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
||||
categories: { backups: 'off-box' },
|
||||
}));
|
||||
const offBox = service.state().backends.find((b) => b.name === 'off-box')!;
|
||||
const offBox = (await service.state()).backends.find((b) => b.name === 'off-box')!;
|
||||
expect(offBox.options.secretAccessKey).toBe(MASKED_SETTING_VALUE);
|
||||
expect(offBox.options.accessKeyId).toBe('ak');
|
||||
});
|
||||
|
||||
it('STORADM-003 surfaces replica failures through StorageService.health()', () => {
|
||||
const { service, registry } = makeService();
|
||||
it('STORADM-003 surfaces replica failures through StorageService.health()', async () => {
|
||||
const { service, registry } = await makeService();
|
||||
registry.recordReplicaFailure({ backend: 'nas', key: 'backup-1.zip', op: 'put', error: 'disk full', at: 123 });
|
||||
expect(service.state().health.replicaFailures).toEqual([
|
||||
expect((await service.state()).health.replicaFailures).toEqual([
|
||||
{ backend: 'nas', key: 'backup-1.zip', op: 'put', error: 'disk full', at: 123 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('STORADM-027 configError is null on a clean load, and mirrors registry.lastLoadError() after a bad reload', () => {
|
||||
it('STORADM-027 configError is null on a clean load, and mirrors registry.lastLoadError() after a bad reload', async () => {
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { service, registry } = makeService();
|
||||
expect(service.state().configError).toBeNull();
|
||||
const { service, registry } = await makeService();
|
||||
expect((await service.state()).configError).toBeNull();
|
||||
|
||||
setSetting(CATEGORIES_KEY, 'garbage {');
|
||||
registry.reload();
|
||||
expect(service.state().configError).toBe(registry.lastLoadError());
|
||||
expect(service.state().configError).not.toBeNull();
|
||||
await registry.reload();
|
||||
expect((await service.state()).configError).toBe(registry.lastLoadError());
|
||||
expect((await service.state()).configError).not.toBeNull();
|
||||
});
|
||||
|
||||
it('STORADM-026 state embeds usage (null until computed) and live backfill statuses', async () => {
|
||||
const { service, stats } = makeService();
|
||||
expect(service.state().usage).toBeNull();
|
||||
expect(service.state().backfills).toEqual([]);
|
||||
const { service, stats } = await makeService();
|
||||
expect((await service.state()).usage).toBeNull();
|
||||
expect((await service.state()).backfills).toEqual([]);
|
||||
await stats.scan();
|
||||
expect(service.state().usage).not.toBeNull();
|
||||
expect(service.state().usage!.computedAt).toBeGreaterThan(0);
|
||||
expect((await service.state()).usage).not.toBeNull();
|
||||
expect((await service.state()).usage!.computedAt).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorageAdminService.applyConfig', () => {
|
||||
it('STORADM-010 happy path: persists both rows, reloads, new config is live', () => {
|
||||
const { service, registry, uploadsRoot } = makeService();
|
||||
it('STORADM-010 happy path: persists both rows, reloads, new config is live', async () => {
|
||||
const { service, registry, uploadsRoot } = await makeService();
|
||||
const nasRoot = makeTmpDir();
|
||||
put(service, configWith(uploadsRoot, {
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'nas-backups', type: 'local', options: { root: nasRoot } }],
|
||||
categories: { backups: 'nas-backups' },
|
||||
}));
|
||||
@@ -168,9 +170,9 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
expect(JSON.parse(readRow(CATEGORIES_KEY)!)).toEqual({ backups: 'nas-backups' });
|
||||
});
|
||||
|
||||
it('STORADM-011 encrypts plaintext secrets at rest', () => {
|
||||
const { service, uploadsRoot } = makeService();
|
||||
put(service, configWith(uploadsRoot, {
|
||||
it('STORADM-011 encrypts plaintext secrets at rest', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
||||
categories: { backups: 'off-box' },
|
||||
}));
|
||||
@@ -179,16 +181,16 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
expect(secret.startsWith('enc:v1:')).toBe(true);
|
||||
});
|
||||
|
||||
it('STORADM-012 mask echo preserves the stored ciphertext byte-for-byte', () => {
|
||||
const { service, uploadsRoot } = makeService();
|
||||
put(service, configWith(uploadsRoot, {
|
||||
it('STORADM-012 mask echo preserves the stored ciphertext byte-for-byte', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
||||
categories: { backups: 'off-box' },
|
||||
}));
|
||||
const before = JSON.parse(readRow(BACKENDS_KEY)!) as Array<{ name: string; options: Record<string, unknown> }>;
|
||||
const cipherBefore = before.find((b) => b.name === 'off-box')!.options.secretAccessKey;
|
||||
|
||||
put(service, configWith(uploadsRoot, {
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }],
|
||||
categories: { backups: 'off-box' },
|
||||
}));
|
||||
@@ -196,22 +198,22 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
expect(after.find((b) => b.name === 'off-box')!.options.secretAccessKey).toBe(cipherBefore);
|
||||
});
|
||||
|
||||
it('STORADM-013 a mask on a renamed/new backend throws the re-enter error, persists nothing', () => {
|
||||
const { service, uploadsRoot } = makeService();
|
||||
it('STORADM-013 a mask on a renamed/new backend throws the re-enter error, persists nothing', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
const before = readRow(BACKENDS_KEY);
|
||||
expect(() =>
|
||||
await expect(
|
||||
put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'brand-new', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: MASKED_SETTING_VALUE } }],
|
||||
categories: {},
|
||||
})),
|
||||
).toThrow("re-enter the secret 'secretAccessKey' for 'brand-new'");
|
||||
).rejects.toThrow("re-enter the secret 'secretAccessKey' for 'brand-new'");
|
||||
expect(readRow(BACKENDS_KEY)).toBe(before);
|
||||
});
|
||||
|
||||
it('STORADM-014 a plaintext secret saves without an explicit ENCRYPTION_KEY and is still encrypted at rest', () => {
|
||||
it('STORADM-014 a plaintext secret saves without an explicit ENCRYPTION_KEY and is still encrypted at rest', async () => {
|
||||
// No key-presence gate: the implicit key covers encryption when ENCRYPTION_KEY is unset.
|
||||
const { service, uploadsRoot } = makeService();
|
||||
put(service, configWith(uploadsRoot, {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'off-box', type: 's3', options: S3_OPTIONS }],
|
||||
categories: {},
|
||||
}));
|
||||
@@ -220,52 +222,53 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
expect(offBox.options.secretAccessKey.startsWith('enc:v1:')).toBe(true);
|
||||
});
|
||||
|
||||
it('STORADM-015 an encrypted (mask-echoed or enc:v1:) secret resaves fine', () => {
|
||||
it('STORADM-015 an encrypted (mask-echoed or enc:v1:) secret resaves fine', async () => {
|
||||
// Resaving stored ciphertext must not lock admins out.
|
||||
const { service, uploadsRoot } = makeService();
|
||||
put(service, configWith(uploadsRoot, {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, secretAccessKey: encrypt_api_key('sk') } }],
|
||||
categories: { backups: 'off-box' },
|
||||
}));
|
||||
expect(readRow(BACKENDS_KEY)).toBeDefined();
|
||||
});
|
||||
|
||||
it('STORADM-016 preview() refusals surface verbatim and persist nothing', () => {
|
||||
const { service, uploadsRoot, registry } = makeService();
|
||||
it('STORADM-016 preview() refusals surface verbatim and persist nothing', async () => {
|
||||
const { service, uploadsRoot, registry } = await makeService();
|
||||
const before = readRow(CATEGORIES_KEY);
|
||||
expect(() =>
|
||||
await expect(
|
||||
put(service, configWith(uploadsRoot, { categories: { backups: 'nope' } })),
|
||||
).toThrow("category 'backups' maps to unknown backend 'nope'");
|
||||
).rejects.toThrow("category 'backups' maps to unknown backend 'nope'");
|
||||
expect(readRow(CATEGORIES_KEY)).toBe(before);
|
||||
expect(registry.resolve('backups').backendName).toBe('backups-local'); // live state untouched
|
||||
});
|
||||
|
||||
it('STORADM-018 a mask sentinel in a NON-secret field throws and persists nothing', () => {
|
||||
const { service, uploadsRoot } = makeService();
|
||||
it('STORADM-018 a mask sentinel in a NON-secret field throws and persists nothing', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
const before = readRow(BACKENDS_KEY);
|
||||
expect(() =>
|
||||
await expect(
|
||||
put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'off-box', type: 's3', options: { ...S3_OPTIONS, accessKeyId: MASKED_SETTING_VALUE } }],
|
||||
categories: {},
|
||||
})),
|
||||
).toThrow("backend 'off-box' field 'accessKeyId' is the mask sentinel — a mask can never become a stored value");
|
||||
).rejects.toThrow("backend 'off-box' field 'accessKeyId' is the mask sentinel — a mask can never become a stored value");
|
||||
expect(readRow(BACKENDS_KEY)).toBe(before);
|
||||
});
|
||||
|
||||
it('STORADM-017 persists both rows in ONE transaction and reloads once', () => {
|
||||
const { service, registry, uploadsRoot } = makeService();
|
||||
const txSpy = vi.spyOn(db, 'transaction');
|
||||
it('STORADM-017 persists both rows in ONE transaction and reloads once', async () => {
|
||||
const { service, registry, uploadsRoot, uow } = await makeService();
|
||||
// The one transaction is now UnitOfWork.transactional, not db.transaction.
|
||||
const txSpy = vi.spyOn(uow, 'transactional');
|
||||
const reloadSpy = vi.spyOn(registry, 'reload');
|
||||
put(service, configWith(uploadsRoot));
|
||||
await put(service, configWith(uploadsRoot));
|
||||
expect(txSpy).toHaveBeenCalledTimes(1);
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('STORADM-019 applyConfig invokes the dissolved-job cancellation after reload', () => {
|
||||
const { service, registry, uploadsRoot, jobs } = makeService();
|
||||
it('STORADM-019 applyConfig invokes the dissolved-job cancellation after reload', async () => {
|
||||
const { service, registry, uploadsRoot, jobs } = await makeService();
|
||||
const reloadSpy = vi.spyOn(registry, 'reload');
|
||||
const cancelSpy = vi.spyOn(jobs, 'cancelJobsForMissingBackends');
|
||||
put(service, configWith(uploadsRoot));
|
||||
await put(service, configWith(uploadsRoot));
|
||||
expect(cancelSpy).toHaveBeenCalledTimes(1);
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1);
|
||||
const reloadOrder = reloadSpy.mock.invocationCallOrder[0]!;
|
||||
@@ -273,25 +276,25 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
expect(cancelOrder).toBeGreaterThan(reloadOrder);
|
||||
});
|
||||
|
||||
it('STORADM-030 a successful save bumps the version counter by exactly one', () => {
|
||||
const { service, uploadsRoot } = makeService();
|
||||
expect(service.state().version).toBe(0);
|
||||
put(service, configWith(uploadsRoot));
|
||||
expect(service.state().version).toBe(1);
|
||||
put(service, configWith(uploadsRoot));
|
||||
expect(service.state().version).toBe(2);
|
||||
it('STORADM-030 a successful save bumps the version counter by exactly one', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
expect((await service.state()).version).toBe(0);
|
||||
await put(service, configWith(uploadsRoot));
|
||||
expect((await service.state()).version).toBe(1);
|
||||
await put(service, configWith(uploadsRoot));
|
||||
expect((await service.state()).version).toBe(2);
|
||||
});
|
||||
|
||||
it('STORADM-031 a stale submitted version throws StorageConflictError and persists nothing (→ 409, audit #7)', () => {
|
||||
const { service, uploadsRoot } = makeService();
|
||||
put(service, configWith(uploadsRoot)); // version is now 1
|
||||
it('STORADM-031 a stale submitted version throws StorageConflictError and persists nothing (→ 409, audit #7)', async () => {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot)); // version is now 1
|
||||
const beforeBackends = readRow(BACKENDS_KEY);
|
||||
const beforeCategories = readRow(CATEGORIES_KEY);
|
||||
expect(() =>
|
||||
await expect(
|
||||
service.applyConfig({ ...configWith(uploadsRoot), version: 0 }), // stale — current is 1
|
||||
).toThrow(StorageConflictError);
|
||||
).rejects.toThrow(StorageConflictError);
|
||||
try {
|
||||
service.applyConfig({ ...configWith(uploadsRoot), version: 0 });
|
||||
await service.applyConfig({ ...configWith(uploadsRoot), version: 0 });
|
||||
expect.unreachable('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(StorageConflictError);
|
||||
@@ -300,24 +303,24 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
}
|
||||
expect(readRow(BACKENDS_KEY)).toBe(beforeBackends);
|
||||
expect(readRow(CATEGORIES_KEY)).toBe(beforeCategories);
|
||||
expect(service.state().version).toBe(1); // unchanged — the conflicting save never wrote
|
||||
expect((await service.state()).version).toBe(1); // unchanged — the conflicting save never wrote
|
||||
});
|
||||
|
||||
it('STORADM-032 the version check runs before preview/unmask — a stale submit never reaches those refusals', () => {
|
||||
it('STORADM-032 the version check runs before preview/unmask — a stale submit never reaches those refusals', async () => {
|
||||
// If the version check ran AFTER preview, this would throw the registry's
|
||||
// "unknown backend" StorageBackendError instead of StorageConflictError.
|
||||
const { service, uploadsRoot } = makeService();
|
||||
put(service, configWith(uploadsRoot)); // version is now 1
|
||||
expect(() =>
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot)); // version is now 1
|
||||
await expect(
|
||||
service.applyConfig({ ...configWith(uploadsRoot, { categories: { backups: 'nope' } }), version: 0 }),
|
||||
).toThrow(StorageConflictError);
|
||||
).rejects.toThrow(StorageConflictError);
|
||||
});
|
||||
|
||||
it('STORADM-033 regression (audit #7): a migration flip bumps the version; a stale admin PUT built before the flip 409s and the flip survives untouched', () => {
|
||||
const { service, registry, uploadsRoot } = makeService();
|
||||
it('STORADM-033 regression (audit #7): a migration flip bumps the version; a stale admin PUT built before the flip 409s and the flip survives untouched', async () => {
|
||||
const { service, registry, uploadsRoot } = await makeService();
|
||||
const destRoot = makeTmpDir();
|
||||
// The admin loads the form: draft carries version 0, categories.files → uploads-local.
|
||||
put(service, configWith(uploadsRoot, {
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'dest', type: 'local', options: { root: destRoot } }],
|
||||
categories: {},
|
||||
})); // version is now 1; 'files' still defaults to uploads-local
|
||||
@@ -325,33 +328,33 @@ describe('StorageAdminService.applyConfig', () => {
|
||||
backends: [{ name: 'dest', type: 'local', options: { root: destRoot } }],
|
||||
categories: {}, // the admin's draft never touched 'files'
|
||||
});
|
||||
const staleVersion = service.state().version; // 1 — what the admin's form is holding
|
||||
const staleVersion = (await service.state()).version; // 1 — what the admin's form is holding
|
||||
|
||||
// Meanwhile, a category migration flips 'files' to 'dest' — the exact
|
||||
// write path assignCategory uses, bumping the shared version counter.
|
||||
registry.assignCategory('files', 'dest');
|
||||
await registry.assignCategory('files', 'dest');
|
||||
expect(registry.snapshot().categories.files.backend).toBe('dest');
|
||||
expect(service.state().version).toBe(2); // the flip bumped it past the admin's stale draft
|
||||
expect((await service.state()).version).toBe(2); // the flip bumped it past the admin's stale draft
|
||||
|
||||
// The admin's stale PUT (still holding version 1, and no opinion on
|
||||
// 'files') must 409, NOT silently reassign 'files' back to its old default.
|
||||
expect(() => service.applyConfig({ ...staleDraft, version: staleVersion })).toThrow(StorageConflictError);
|
||||
await expect(service.applyConfig({ ...staleDraft, version: staleVersion })).rejects.toThrow(StorageConflictError);
|
||||
|
||||
// The flip survives untouched.
|
||||
expect(registry.snapshot().categories.files.backend).toBe('dest');
|
||||
expect(service.state().version).toBe(2);
|
||||
expect((await service.state()).version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorageAdminService.testBackend', () => {
|
||||
it('STORADM-020 probes a healthy local candidate: ok with one green target', async () => {
|
||||
const { service } = makeService();
|
||||
const { service } = await makeService();
|
||||
const result = await service.testBackend({ name: 'cand', type: 'local', options: { root: makeTmpDir() } });
|
||||
expect(result).toEqual({ ok: true, targets: [{ name: 'cand', ok: true }] });
|
||||
});
|
||||
|
||||
it('STORADM-021 an unreachable s3 candidate fails fast with a per-target error (no registry impact)', async () => {
|
||||
const { service, registry } = makeService();
|
||||
const { service, registry } = await makeService();
|
||||
const result = await service.testBackend({
|
||||
name: 'cand',
|
||||
type: 's3',
|
||||
@@ -381,8 +384,8 @@ describe('StorageAdminService.testBackend', () => {
|
||||
// corrupt it afterward — testBackend's ephemeralDriverFor re-runs
|
||||
// init() fresh at probe time and hits the same EEXIST there instead.
|
||||
const badRoot = path.join(makeTmpDir(), 'a-file');
|
||||
const { service, uploadsRoot } = makeService();
|
||||
put(service, configWith(uploadsRoot, {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [
|
||||
{ name: 'good-local', type: 'local', options: { root: goodRoot } },
|
||||
{ name: 'bad-local', type: 'local', options: { root: badRoot } },
|
||||
@@ -404,16 +407,16 @@ describe('StorageAdminService.testBackend', () => {
|
||||
});
|
||||
|
||||
it('STORADM-023 a mirror referencing an unknown or mirror-typed backend throws (→ 400)', async () => {
|
||||
const { service } = makeService();
|
||||
const { service } = await makeService();
|
||||
await expect(
|
||||
service.testBackend({ name: 'm', type: 'mirror', options: { primary: 'nope', replicas: [] } }),
|
||||
).rejects.toThrow("mirror 'm' references unknown backend 'nope'");
|
||||
});
|
||||
|
||||
it('STORADM-024 unmasks a stored backend by name before probing (mask echo works on /test)', async () => {
|
||||
const { service, uploadsRoot } = makeService();
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
const root = makeTmpDir();
|
||||
put(service, configWith(uploadsRoot, {
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [{ name: 'nas', type: 'local', options: { root } }],
|
||||
categories: {},
|
||||
}));
|
||||
@@ -438,8 +441,8 @@ describe('StorageAdminService.testBackend', () => {
|
||||
|
||||
it('STORADM-025 a mirror with a stored s3 replica decrypts the enc:v1: secret for the probe', async () => {
|
||||
const goodRoot = makeTmpDir();
|
||||
const { service, uploadsRoot } = makeService();
|
||||
put(service, configWith(uploadsRoot, {
|
||||
const { service, uploadsRoot } = await makeService();
|
||||
await put(service, configWith(uploadsRoot, {
|
||||
backends: [
|
||||
{ name: 'good-local', type: 'local', options: { root: goodRoot } },
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MigrationTargetError,
|
||||
StorageJobsService,
|
||||
} from '../../../../src/nest/storage/storage-jobs.service';
|
||||
import { createTestUnitOfWork } from '../../../helpers/test-uow';
|
||||
|
||||
const db = new DatabaseService(testDb);
|
||||
|
||||
@@ -56,7 +57,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
/** A registry whose backups category routes through mirror 'm' (nas replica). */
|
||||
function makeWorld() {
|
||||
async function makeWorld() {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const backupsRoot = makeTmpDir();
|
||||
const nasRoot = makeTmpDir();
|
||||
@@ -71,8 +72,8 @@ function makeWorld() {
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ backups: 'm' }));
|
||||
const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService;
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService());
|
||||
registry.onModuleInit();
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb));
|
||||
await registry.onModuleInit();
|
||||
const storage = new StorageService(registry);
|
||||
const jobs = new StorageJobsService(registry);
|
||||
return { registry, storage, jobs, backupsRoot, nasRoot };
|
||||
@@ -87,7 +88,7 @@ async function waitFor(predicate: () => boolean, ms = 5000, intervalMs = 20): Pr
|
||||
}
|
||||
|
||||
/** A registry with 'files' explicitly routed to 'uploads-local' and an unassigned 'dest-local' target. */
|
||||
function makeMigrationWorld() {
|
||||
async function makeMigrationWorld() {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const backupsRoot = makeTmpDir();
|
||||
const destRoot = makeTmpDir();
|
||||
@@ -101,8 +102,8 @@ function makeMigrationWorld() {
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' }));
|
||||
const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService;
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService());
|
||||
registry.onModuleInit();
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb));
|
||||
await registry.onModuleInit();
|
||||
const storage = new StorageService(registry);
|
||||
const jobs = new StorageJobsService(registry);
|
||||
return { registry, storage, jobs, uploadsRoot, destRoot };
|
||||
@@ -114,7 +115,7 @@ function makeMigrationWorld() {
|
||||
* plus an unassigned 'dest-local' target that uses the normal prefixed
|
||||
* (mode-B) layout for every other backend.
|
||||
*/
|
||||
function makePhotosGoogleMigrationWorld() {
|
||||
async function makePhotosGoogleMigrationWorld() {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const backupsRoot = makeTmpDir();
|
||||
const placePhotoRoot = makeTmpDir();
|
||||
@@ -128,15 +129,15 @@ function makePhotosGoogleMigrationWorld() {
|
||||
]),
|
||||
);
|
||||
const env = { env: () => ({ paths: { placePhotoDir: placePhotoRoot } }) } as unknown as RuntimeEnvService;
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService());
|
||||
registry.onModuleInit();
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb));
|
||||
await registry.onModuleInit();
|
||||
const storage = new StorageService(registry);
|
||||
const jobs = new StorageJobsService(registry);
|
||||
return { registry, storage, jobs, placePhotoRoot, destRoot };
|
||||
}
|
||||
|
||||
/** Combines the migration world's 'dest-local' target with the backfill world's routed mirror 'm'. */
|
||||
function makeMigrationBackfillWorld() {
|
||||
async function makeMigrationBackfillWorld() {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const backupsRoot = makeTmpDir();
|
||||
const nasRoot = makeTmpDir();
|
||||
@@ -153,8 +154,8 @@ function makeMigrationBackfillWorld() {
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ backups: 'm', files: 'uploads-local' }));
|
||||
const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService;
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService());
|
||||
registry.onModuleInit();
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb));
|
||||
await registry.onModuleInit();
|
||||
const storage = new StorageService(registry);
|
||||
const jobs = new StorageJobsService(registry);
|
||||
return { registry, storage, jobs, uploadsRoot, backupsRoot, nasRoot, destRoot };
|
||||
@@ -176,7 +177,7 @@ async function waitTerminal(jobs: StorageJobsService, category: string) {
|
||||
|
||||
describe('StorageJobsService', () => {
|
||||
it('JOBS-001 backfills the categories routed through the mirror and lands on done', async () => {
|
||||
const { storage, jobs, nasRoot } = makeWorld();
|
||||
const { storage, jobs, nasRoot } = await makeWorld();
|
||||
await storage.put('backups', 'old-backup.zip', Readable.from('zipzip'));
|
||||
fs.rmSync(path.join(nasRoot, 'old-backup.zip'), { force: true }); // simulate pre-mirror object
|
||||
jobs.startBackfill('m');
|
||||
@@ -186,14 +187,14 @@ describe('StorageJobsService', () => {
|
||||
expect(fs.existsSync(path.join(nasRoot, 'old-backup.zip'))).toBe(true);
|
||||
});
|
||||
|
||||
it('JOBS-002 rejects an unrouted or non-mirror name with BackfillTargetError', () => {
|
||||
const { jobs } = makeWorld();
|
||||
it('JOBS-002 rejects an unrouted or non-mirror name with BackfillTargetError', async () => {
|
||||
const { jobs } = await makeWorld();
|
||||
expect(() => jobs.startBackfill('nas')).toThrow(BackfillTargetError);
|
||||
expect(() => jobs.startBackfill('ghost')).toThrow(BackfillTargetError);
|
||||
});
|
||||
|
||||
it('JOBS-003 a second start while one runs throws BackfillBusyError (global, either backend)', async () => {
|
||||
const { storage, jobs } = makeWorld();
|
||||
const { storage, jobs } = await makeWorld();
|
||||
for (let i = 0; i < 20; i++) await storage.put('backups', `b${i}.zip`, Readable.from('x'.repeat(1000)));
|
||||
jobs.startBackfill('m');
|
||||
expect(() => jobs.startBackfill('m')).toThrow(BackfillBusyError);
|
||||
@@ -201,7 +202,7 @@ describe('StorageJobsService', () => {
|
||||
});
|
||||
|
||||
it('JOBS-004 cancel flips a running job to cancelled; cancelling a finished/unknown one returns false', async () => {
|
||||
const { storage, jobs } = makeWorld();
|
||||
const { storage, jobs } = await makeWorld();
|
||||
for (let i = 0; i < 50; i++) await storage.put('backups', `c${i}.zip`, Readable.from('y'.repeat(2000)));
|
||||
jobs.startBackfill('m');
|
||||
expect(jobs.cancelBackfill('m')).toBe(true);
|
||||
@@ -214,7 +215,7 @@ describe('StorageJobsService', () => {
|
||||
it('JOBS-005 finished statuses expire after the TTL', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { storage, jobs } = makeWorld();
|
||||
const { storage, jobs } = await makeWorld();
|
||||
await storage.put('backups', 'one.zip', Readable.from('z'));
|
||||
jobs.startBackfill('m');
|
||||
// Drain the real async job under fake timers by flushing microtasks.
|
||||
@@ -227,7 +228,7 @@ describe('StorageJobsService', () => {
|
||||
});
|
||||
|
||||
it('JOBS-006 an Error rejection from driver.backfill lands the job on "error" with its message, and is logged', async () => {
|
||||
const { jobs } = makeWorld();
|
||||
const { jobs } = await makeWorld();
|
||||
const backfillSpy = vi.spyOn(MirrorDriver.prototype, 'backfill').mockRejectedValueOnce(new Error('replica offline'));
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jobs.startBackfill('m');
|
||||
@@ -239,7 +240,7 @@ describe('StorageJobsService', () => {
|
||||
});
|
||||
|
||||
it('JOBS-006b a non-Error rejection is stringified rather than crashing', async () => {
|
||||
const { jobs } = makeWorld();
|
||||
const { jobs } = await makeWorld();
|
||||
const backfillSpy = vi.spyOn(MirrorDriver.prototype, 'backfill').mockRejectedValueOnce('replica gone');
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
jobs.startBackfill('m');
|
||||
@@ -249,7 +250,7 @@ describe('StorageJobsService', () => {
|
||||
});
|
||||
|
||||
it('JOBS-007 withTtl builds a service whose finished jobs expire on the given (short) TTL, not the 10-minute default', async () => {
|
||||
const { registry, storage } = makeWorld();
|
||||
const { registry, storage } = await makeWorld();
|
||||
await storage.put('backups', 'q.zip', Readable.from('q'));
|
||||
const jobs = StorageJobsService.withTtl(registry, 50);
|
||||
jobs.startBackfill('m');
|
||||
@@ -259,7 +260,7 @@ describe('StorageJobsService', () => {
|
||||
});
|
||||
|
||||
it('JOBS-008 while a backfill is running, starting an unknown/non-mirror name throws BackfillTargetError, not BackfillBusyError', async () => {
|
||||
const { storage, jobs } = makeWorld();
|
||||
const { storage, jobs } = await makeWorld();
|
||||
for (let i = 0; i < 20; i++) await storage.put('backups', `d${i}.zip`, Readable.from('x'.repeat(1000)));
|
||||
jobs.startBackfill('m');
|
||||
expect(() => jobs.startBackfill('ghost')).toThrow(BackfillTargetError);
|
||||
@@ -270,14 +271,14 @@ describe('StorageJobsService', () => {
|
||||
|
||||
describe('StorageJobsService migrations', () => {
|
||||
it('MIG-001 happy path: copies, flips the category, sweeps a raced write, tallies reclaimable', async () => {
|
||||
const { storage, jobs, uploadsRoot, destRoot } = makeMigrationWorld();
|
||||
const { storage, jobs, uploadsRoot, destRoot } = await makeMigrationWorld();
|
||||
// Sized up (not slept) so the copy phase has real work left when we poll
|
||||
// for enumeration-done — a few bytes would finish before the first poll tick.
|
||||
const big = 'a'.repeat(4_000_000);
|
||||
await storage.put('files', 'a.txt', Readable.from(big));
|
||||
await storage.put('files', 'b.txt', Readable.from(big.replace(/a/g, 'b')));
|
||||
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
// Enumeration done — write the raced third object before the copy phase settles.
|
||||
// Tight poll interval: the window between "total > 0" and job completion is narrow.
|
||||
await waitFor(
|
||||
@@ -301,13 +302,13 @@ describe('StorageJobsService migrations', () => {
|
||||
});
|
||||
|
||||
it('MIG-002 a copy failure blocks the flip and ends failed', async () => {
|
||||
const { storage, jobs, destRoot } = makeMigrationWorld();
|
||||
const { storage, jobs, destRoot } = await makeMigrationWorld();
|
||||
await storage.put('files', 'a.txt', Readable.from('aaa'));
|
||||
await storage.put('files', 'b.txt', Readable.from('bbb'));
|
||||
// Pre-create a DIRECTORY at the target key path so the put's rename fails.
|
||||
fs.mkdirSync(path.join(destRoot, 'files', 'a.txt'), { recursive: true });
|
||||
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
expect(final.status).toBe('failed');
|
||||
expect(final.failed).toBeGreaterThan(0);
|
||||
@@ -315,12 +316,12 @@ describe('StorageJobsService migrations', () => {
|
||||
});
|
||||
|
||||
it('MIG-003 cancel before the flip leaves everything untouched', async () => {
|
||||
const { storage, jobs } = makeMigrationWorld();
|
||||
const { storage, jobs } = await makeMigrationWorld();
|
||||
// Many, largish objects (not sleeps) so the copy phase is still running
|
||||
// when we poll for done >= 1 and issue the cancel.
|
||||
for (let i = 0; i < 100; i++) await storage.put('files', `f${i}.txt`, Readable.from('x'.repeat(100_000)));
|
||||
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1);
|
||||
expect(jobs.cancelMigration('files')).toBe(true);
|
||||
|
||||
@@ -333,13 +334,17 @@ describe('StorageJobsService migrations', () => {
|
||||
// Deterministic route (no timing race): an EMPTY category never enters
|
||||
// either copy loop, so the only place a cancel can be observed is the
|
||||
// guard between the failed-check and the flip. `cancelMigration` is
|
||||
// called synchronously right after `startMigration` returns — the
|
||||
// called synchronously right after `startMigration`'s own body runs — the
|
||||
// detached runMigration's first `for await` suspends on a microtask
|
||||
// before doing any work, so this synchronous call always lands the
|
||||
// cancel flag before the async function resumes.
|
||||
const { jobs } = makeMigrationWorld(); // 'files' has zero objects
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
// cancel flag before the async function resumes. `startMigration` is now
|
||||
// async, so its promise is held and awaited AFTER the cancel rather than
|
||||
// before it: awaiting first would hand runMigration a turn and the flip
|
||||
// could land before the cancel.
|
||||
const { jobs } = await makeMigrationWorld(); // 'files' has zero objects
|
||||
const started = jobs.startMigration('files', 'dest-local');
|
||||
expect(jobs.cancelMigration('files')).toBe(true);
|
||||
await started;
|
||||
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
expect(final.status).toBe('cancelled');
|
||||
@@ -347,33 +352,33 @@ describe('StorageJobsService migrations', () => {
|
||||
});
|
||||
|
||||
it('MIG-004 validations: 400s, 404, and 409 against a running backfill (and vice versa)', async () => {
|
||||
const { storage, jobs } = makeMigrationBackfillWorld();
|
||||
expect(() => jobs.startMigration('files', 'uploads-local')).toThrow(MigrationRequestError); // to === current
|
||||
expect(() => jobs.startMigration('nope' as never, 'dest-local')).toThrow(MigrationRequestError);
|
||||
expect(() => jobs.startMigration('files', 'ghost')).toThrow(MigrationTargetError);
|
||||
const { storage, jobs } = await makeMigrationBackfillWorld();
|
||||
await expect(jobs.startMigration('files', 'uploads-local')).rejects.toThrow(MigrationRequestError); // to === current
|
||||
await expect(jobs.startMigration('nope' as never, 'dest-local')).rejects.toThrow(MigrationRequestError);
|
||||
await expect(jobs.startMigration('files', 'ghost')).rejects.toThrow(MigrationTargetError);
|
||||
|
||||
// A running backfill blocks a migration start.
|
||||
for (let i = 0; i < 20; i++) await storage.put('backups', `b${i}.zip`, Readable.from('x'.repeat(1000)));
|
||||
jobs.startBackfill('m');
|
||||
expect(() => jobs.startMigration('files', 'dest-local')).toThrow(BackfillBusyError);
|
||||
await expect(jobs.startMigration('files', 'dest-local')).rejects.toThrow(BackfillBusyError);
|
||||
await waitFor(() => jobs.statuses().some((s) => s.status !== 'running'));
|
||||
|
||||
// A running migration blocks a backfill start too.
|
||||
await storage.put('files', 'a.txt', Readable.from('a'.repeat(5000)));
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
expect(() => jobs.startBackfill('m')).toThrow(BackfillBusyError);
|
||||
await waitFor(() => jobs.migrationStatuses().some((m) => m.status !== 'running'));
|
||||
});
|
||||
|
||||
it('MIG-005 an object already on the target with a matching size is skipped in the copy phase itself', async () => {
|
||||
const { storage, jobs, destRoot } = makeMigrationWorld();
|
||||
const { storage, jobs, destRoot } = await makeMigrationWorld();
|
||||
await storage.put('files', 'a.txt', Readable.from('aaa'));
|
||||
// Pre-populate the destination with a byte-identical copy — the shape of
|
||||
// a retried/resumed migration, not just the delta sweep's territory.
|
||||
fs.mkdirSync(path.join(destRoot, 'files'), { recursive: true });
|
||||
fs.writeFileSync(path.join(destRoot, 'files', 'a.txt'), 'aaa');
|
||||
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
expect(final.status).toBe('done');
|
||||
expect(final.skipped).toBeGreaterThanOrEqual(1);
|
||||
@@ -382,7 +387,7 @@ describe('StorageJobsService migrations', () => {
|
||||
});
|
||||
|
||||
it('MIG-006 a sweep-phase copy failure is reported without undoing the already-flipped category', async () => {
|
||||
const { storage, jobs, registry, uploadsRoot, destRoot } = makeMigrationWorld();
|
||||
const { storage, jobs, registry, uploadsRoot, destRoot } = await makeMigrationWorld();
|
||||
await storage.put('files', 'a.txt', Readable.from('aaa'));
|
||||
|
||||
// Grab the exact driver instance startMigration will resolve and keep for
|
||||
@@ -404,7 +409,7 @@ describe('StorageJobsService migrations', () => {
|
||||
return originalList(prefix);
|
||||
});
|
||||
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
expect(final.status).toBe('done'); // sweep failures don't undo a completed flip
|
||||
expect(final.failed).toBeGreaterThanOrEqual(1);
|
||||
@@ -412,11 +417,11 @@ describe('StorageJobsService migrations', () => {
|
||||
});
|
||||
|
||||
it('MIG-008 photos-google mode-A (bare "" prefix) migrating to a prefixed backend rewrites destination keys (audit #8)', async () => {
|
||||
const { storage, jobs, placePhotoRoot, destRoot } = makePhotosGoogleMigrationWorld();
|
||||
const { storage, jobs, placePhotoRoot, destRoot } = await makePhotosGoogleMigrationWorld();
|
||||
await storage.put('photos-google', 'abc.jpg', Readable.from('img-a'));
|
||||
await storage.put('photos-google', 'sub/def.jpg', Readable.from('img-b'));
|
||||
|
||||
jobs.startMigration('photos-google', 'dest-local');
|
||||
await jobs.startMigration('photos-google', 'dest-local');
|
||||
const final = await waitTerminal(jobs, 'photos-google');
|
||||
|
||||
expect(final.status).toBe('done');
|
||||
@@ -434,7 +439,7 @@ describe('StorageJobsService migrations', () => {
|
||||
});
|
||||
|
||||
it('MIG-009 the skip-check on a rewritten-prefix migration stats the DESTINATION key, not the source key', async () => {
|
||||
const { storage, jobs, destRoot } = makePhotosGoogleMigrationWorld();
|
||||
const { storage, jobs, destRoot } = await makePhotosGoogleMigrationWorld();
|
||||
await storage.put('photos-google', 'abc.jpg', Readable.from('same-bytes'));
|
||||
// Pre-populate the destination at the REWRITTEN key with matching size —
|
||||
// if the skip-check mistakenly stat'd the bare source key on the target,
|
||||
@@ -442,7 +447,7 @@ describe('StorageJobsService migrations', () => {
|
||||
fs.mkdirSync(path.join(destRoot, 'photos', 'google'), { recursive: true });
|
||||
fs.writeFileSync(path.join(destRoot, 'photos', 'google', 'abc.jpg'), 'same-bytes');
|
||||
|
||||
jobs.startMigration('photos-google', 'dest-local');
|
||||
await jobs.startMigration('photos-google', 'dest-local');
|
||||
const final = await waitTerminal(jobs, 'photos-google');
|
||||
|
||||
expect(final.status).toBe('done');
|
||||
@@ -452,11 +457,11 @@ describe('StorageJobsService migrations', () => {
|
||||
});
|
||||
|
||||
it('MIG-010 the delta sweep rewrites a raced object\'s destination key too', async () => {
|
||||
const { storage, jobs, placePhotoRoot, destRoot } = makePhotosGoogleMigrationWorld();
|
||||
const { storage, jobs, placePhotoRoot, destRoot } = await makePhotosGoogleMigrationWorld();
|
||||
const big = 'x'.repeat(2_000_000);
|
||||
await storage.put('photos-google', 'a.jpg', Readable.from(big));
|
||||
|
||||
jobs.startMigration('photos-google', 'dest-local');
|
||||
await jobs.startMigration('photos-google', 'dest-local');
|
||||
await waitFor(
|
||||
() => jobs.migrationStatuses().some((m) => m.category === 'photos-google' && m.total > 0),
|
||||
5000,
|
||||
@@ -474,10 +479,10 @@ describe('StorageJobsService migrations', () => {
|
||||
});
|
||||
|
||||
it('MIG-011 equal-prefix migrations (files -> dest-local) stay byte-identical: destination key equals the source key', async () => {
|
||||
const { storage, jobs, destRoot } = makeMigrationWorld();
|
||||
const { storage, jobs, destRoot } = await makeMigrationWorld();
|
||||
await storage.put('files', 'a.txt', Readable.from('aaa'));
|
||||
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
|
||||
expect(final.status).toBe('done');
|
||||
@@ -491,7 +496,7 @@ describe('StorageJobsService migrations', () => {
|
||||
|
||||
describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
it('JOBS-020 cancels a running backfill whose mirror left the config', async () => {
|
||||
const { storage, jobs, registry } = makeWorld();
|
||||
const { storage, jobs, registry } = await makeWorld();
|
||||
for (let i = 0; i < 50; i++) await storage.put('backups', `c${i}.zip`, Readable.from('y'.repeat(2000)));
|
||||
jobs.startBackfill('m');
|
||||
await waitFor(() => jobs.statuses().some((s) => s.backend === 'm' && s.done >= 1), 5000, 1);
|
||||
@@ -500,7 +505,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
// to the built-in default ('backups-local'), a self-consistent config.
|
||||
setSetting('storage.backends', JSON.stringify([]));
|
||||
setSetting('storage.categories', JSON.stringify({}));
|
||||
registry.reload();
|
||||
await registry.reload();
|
||||
|
||||
jobs.cancelJobsForMissingBackends();
|
||||
await waitFor(() => jobs.statuses().some((s) => s.backend === 'm' && s.status !== 'running'));
|
||||
@@ -525,13 +530,13 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ files: 'nas' }));
|
||||
const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService;
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService());
|
||||
registry.onModuleInit();
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb));
|
||||
await registry.onModuleInit();
|
||||
const storage = new StorageService(registry);
|
||||
const jobs = new StorageJobsService(registry);
|
||||
|
||||
for (let i = 0; i < 100; i++) await storage.put('files', `f${i}.txt`, Readable.from('x'.repeat(100_000)));
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1);
|
||||
|
||||
// Simulate a config save: 'files' is rerouted to 'uploads-local' and the
|
||||
@@ -546,7 +551,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
]),
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ files: 'uploads-local' }));
|
||||
registry.reload();
|
||||
await registry.reload();
|
||||
|
||||
jobs.cancelJobsForMissingBackends();
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
@@ -554,9 +559,9 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
});
|
||||
|
||||
it('JOBS-022 cancels a running migration re-routed to a third, still-defined backend (route no longer matches `from`)', async () => {
|
||||
const { storage, jobs, registry } = makeMigrationWorld(); // 'files' -> 'uploads-local'; 'dest-local' is the target
|
||||
const { storage, jobs, registry } = await makeMigrationWorld(); // 'files' -> 'uploads-local'; 'dest-local' is the target
|
||||
for (let i = 0; i < 100; i++) await storage.put('files', `f${i}.txt`, Readable.from('x'.repeat(100_000)));
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1);
|
||||
|
||||
// A config save re-routes 'files' to a THIRD backend — both the
|
||||
@@ -574,7 +579,7 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
]),
|
||||
);
|
||||
setSetting('storage.categories', JSON.stringify({ files: 'third-local' }));
|
||||
registry.reload();
|
||||
await registry.reload();
|
||||
|
||||
jobs.cancelJobsForMissingBackends();
|
||||
const final = await waitTerminal(jobs, 'files');
|
||||
@@ -583,9 +588,9 @@ describe('StorageJobsService.cancelJobsForMissingBackends', () => {
|
||||
});
|
||||
|
||||
it('JOBS-023 a save that does not touch the migrating category leaves it running', async () => {
|
||||
const { storage, jobs, registry } = makeMigrationWorld();
|
||||
const { storage, jobs, registry } = await makeMigrationWorld();
|
||||
for (let i = 0; i < 100; i++) await storage.put('files', `f${i}.txt`, Readable.from('x'.repeat(100_000)));
|
||||
jobs.startMigration('files', 'dest-local');
|
||||
await jobs.startMigration('files', 'dest-local');
|
||||
await waitFor(() => jobs.migrationStatuses().some((m) => m.category === 'files' && m.done >= 1), 5000, 1);
|
||||
|
||||
// A save that touches an unrelated category only — 'files' route is untouched.
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SEED_CONFIG_PATH,
|
||||
} from '../../../../src/nest/storage/storage-paths';
|
||||
import { STORAGE_CATEGORIES } from '../../../../src/nest/storage/storage.types';
|
||||
import { createTestUnitOfWork } from '../../../helpers/test-uow';
|
||||
|
||||
const db = new DatabaseService(testDb);
|
||||
|
||||
@@ -88,13 +89,13 @@ interface RegistryOpts {
|
||||
* merge-by-name). The helper seeds that override so unit runs never write
|
||||
* into the real server/uploads.
|
||||
*/
|
||||
function makeRegistry(opts: RegistryOpts = {}) {
|
||||
async function makeRegistry(opts: RegistryOpts = {}) {
|
||||
const uploadsRoot = opts.uploadsRoot ?? makeTmpDir();
|
||||
setSetting('storage.backends', JSON.stringify([uploadsOverride(uploadsRoot), ...(opts.backends ?? [])]));
|
||||
if (opts.categories) setSetting('storage.categories', JSON.stringify(opts.categories));
|
||||
const stub = makeEnvStub({ placePhotoDir: opts.placePhotoDir });
|
||||
const registry = new StorageRegistryService(db, stub.env, new StorageEventsService());
|
||||
if (opts.boot !== false) registry.onModuleInit();
|
||||
const registry = new StorageRegistryService(db, stub.env, new StorageEventsService(), await createTestUnitOfWork(testDb));
|
||||
if (opts.boot !== false) await registry.onModuleInit();
|
||||
return { registry, uploadsRoot, setUploadsRoot: (root: string) => rewriteUploadsOverride(root) };
|
||||
}
|
||||
|
||||
@@ -120,8 +121,8 @@ afterEach(() => {
|
||||
// ── defaults ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StorageRegistryService defaults', () => {
|
||||
it('resolves every category with the built-in defaults and no settings rows', () => {
|
||||
const { registry, uploadsRoot } = makeRegistry();
|
||||
it('resolves every category with the built-in defaults and no settings rows', async () => {
|
||||
const { registry, uploadsRoot } = await makeRegistry();
|
||||
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
expect(registry.resolve(category)).toBeDefined();
|
||||
@@ -145,9 +146,9 @@ describe('StorageRegistryService defaults', () => {
|
||||
expect(googlePhotos.keyPrefix).toBe('photos/google/');
|
||||
});
|
||||
|
||||
it('honors TREK_PLACE_PHOTO_DIR via the conditional third backend (mode B: bare keys)', () => {
|
||||
it('honors TREK_PLACE_PHOTO_DIR via the conditional third backend (mode B: bare keys)', async () => {
|
||||
const photoDir = makeTmpDir();
|
||||
const { registry } = makeRegistry({ placePhotoDir: photoDir });
|
||||
const { registry } = await makeRegistry({ placePhotoDir: photoDir });
|
||||
|
||||
const googlePhotos = registry.resolve('photos-google');
|
||||
expect(googlePhotos.backendName).toBe('place-photos-local');
|
||||
@@ -159,8 +160,8 @@ describe('StorageRegistryService defaults', () => {
|
||||
expect(registry.resolve('photos-trek').keyPrefix).toBe('photos/trek/');
|
||||
});
|
||||
|
||||
it('creates roots, spools, category prefix dirs, and the global temp dir on load', () => {
|
||||
const { uploadsRoot } = makeRegistry();
|
||||
it('creates roots, spools, category prefix dirs, and the global temp dir on load', async () => {
|
||||
const { uploadsRoot } = await makeRegistry();
|
||||
|
||||
for (const sub of ['.tmp', 'files', 'journey', 'covers', 'avatars', 'places', 'photos', 'photos/trek', 'photos/google']) {
|
||||
expect(fs.statSync(path.join(uploadsRoot, sub)).isDirectory()).toBe(true);
|
||||
@@ -169,10 +170,10 @@ describe('StorageRegistryService defaults', () => {
|
||||
expect(fs.statSync(path.join(DEFAULT_BACKUPS_ROOT, '.tmp')).isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('roots uploads-local at the computed default when no override row exists', () => {
|
||||
const { registry } = makeRegistry({ boot: false });
|
||||
it('roots uploads-local at the computed default when no override row exists', async () => {
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'storage.backends'").run();
|
||||
registry.onModuleInit();
|
||||
await registry.onModuleInit();
|
||||
const files = registry.resolve('files');
|
||||
expect(files.backendName).toBe('uploads-local');
|
||||
expect(files.driver.getLocalPath!('files/x.pdf')).toBe(
|
||||
@@ -180,17 +181,17 @@ describe('StorageRegistryService defaults', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('a settings row bearing a built-in name replaces it (first-class override)', () => {
|
||||
it('a settings row bearing a built-in name replaces it (first-class override)', async () => {
|
||||
const relocated = makeTmpDir();
|
||||
const { registry } = makeRegistry({ uploadsRoot: relocated });
|
||||
const { registry } = await makeRegistry({ uploadsRoot: relocated });
|
||||
expect(registry.resolve('files').driver.getLocalPath!('files/x.pdf')).toBe(
|
||||
path.join(fs.realpathSync(relocated), 'files/x.pdf'),
|
||||
);
|
||||
});
|
||||
|
||||
it('serves the legacy photos category from uploads-local without any config (ServedCategory seam)', () => {
|
||||
it('serves the legacy photos category from uploads-local without any config (ServedCategory seam)', async () => {
|
||||
const relocated = makeTmpDir();
|
||||
const { registry } = makeRegistry({ uploadsRoot: relocated });
|
||||
const { registry } = await makeRegistry({ uploadsRoot: relocated });
|
||||
const photos = registry.resolve('photos');
|
||||
expect(photos.backendName).toBe('uploads-local');
|
||||
expect(photos.keyPrefix).toBe('photos/');
|
||||
@@ -200,8 +201,8 @@ describe('StorageRegistryService defaults', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('the admin snapshot exposes only the 8 configurable categories — photos is served, not configurable', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('the admin snapshot exposes only the 8 configurable categories — photos is served, not configurable', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
const keys = Object.keys(registry.snapshot().categories);
|
||||
expect(keys).toHaveLength(8);
|
||||
expect(keys).not.toContain('photos');
|
||||
@@ -211,24 +212,24 @@ describe('StorageRegistryService defaults', () => {
|
||||
// ── keyPrefixFor (extracted for the category-migration job) ───────────────────
|
||||
|
||||
describe('StorageRegistryService keyPrefixFor', () => {
|
||||
it('returns the plain category prefix regardless of which backend is asked', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('returns the plain category prefix regardless of which backend is asked', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
expect(registry.keyPrefixFor('files', 'uploads-local')).toBe('files/');
|
||||
expect(registry.keyPrefixFor('files', 'some-other-backend')).toBe('files/');
|
||||
expect(registry.keyPrefixFor('backups', 'backups-local')).toBe('');
|
||||
expect(registry.keyPrefixFor('covers', 'anything')).toBe('covers/');
|
||||
});
|
||||
|
||||
it('photos-google is bare-key (mode A) ONLY on place-photos-local; every other backend gets the prefixed mode-B layout', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('photos-google is bare-key (mode A) ONLY on place-photos-local; every other backend gets the prefixed mode-B layout', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
expect(registry.keyPrefixFor('photos-google', 'place-photos-local')).toBe('');
|
||||
expect(registry.keyPrefixFor('photos-google', 'uploads-local')).toBe('photos/google/');
|
||||
expect(registry.keyPrefixFor('photos-google', 'dest-local')).toBe('photos/google/');
|
||||
});
|
||||
|
||||
it('agrees with resolve() for the category\'s CURRENT backend — no drift between the two rules', () => {
|
||||
it('agrees with resolve() for the category\'s CURRENT backend — no drift between the two rules', async () => {
|
||||
const photoDir = makeTmpDir();
|
||||
const { registry } = makeRegistry({ placePhotoDir: photoDir });
|
||||
const { registry } = await makeRegistry({ placePhotoDir: photoDir });
|
||||
const resolved = registry.resolve('photos-google');
|
||||
expect(registry.keyPrefixFor('photos-google', resolved.backendName)).toBe(resolved.keyPrefix);
|
||||
});
|
||||
@@ -237,9 +238,9 @@ describe('StorageRegistryService keyPrefixFor', () => {
|
||||
// ── settings merge + validation ───────────────────────────────────────────────
|
||||
|
||||
describe('StorageRegistryService settings', () => {
|
||||
it('merges app_settings backends/categories over the defaults (backup mirror)', () => {
|
||||
it('merges app_settings backends/categories over the defaults (backup mirror)', async () => {
|
||||
const nasRoot = makeTmpDir();
|
||||
const { registry } = makeRegistry({
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [
|
||||
{ name: 'nas-backups', type: 'local', options: { root: nasRoot } },
|
||||
{ name: 'backup-mirror', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas-backups'] } },
|
||||
@@ -254,9 +255,9 @@ describe('StorageRegistryService settings', () => {
|
||||
expect(registry.resolve('files').backendName).toBe('uploads-local');
|
||||
});
|
||||
|
||||
it('a mirror may serve any category — the backups-only rule is lifted (replicas-on-primary spec)', () => {
|
||||
it('a mirror may serve any category — the backups-only rule is lifted (replicas-on-primary spec)', async () => {
|
||||
const extraRoot = makeTmpDir();
|
||||
const { registry, uploadsRoot } = makeRegistry({
|
||||
const { registry, uploadsRoot } = await makeRegistry({
|
||||
backends: [
|
||||
{ name: 'extra-local', type: 'local', options: { root: extraRoot } },
|
||||
{ name: 'm', type: 'mirror', options: { primary: 'uploads-local', replicas: ['extra-local'] } },
|
||||
@@ -292,18 +293,18 @@ describe('StorageRegistryService settings', () => {
|
||||
['unknown category name', undefined, JSON.stringify({ 'not-a-category': 'uploads-local' })],
|
||||
['retired photos category in settings', undefined, JSON.stringify({ photos: 'uploads-local' })],
|
||||
['malformed JSON', 'not json at all', undefined],
|
||||
])('falls back to built-in defaults at boot on invalid settings: %s', (_label, backendsRow, categoriesRow) => {
|
||||
const { registry } = makeRegistry({ boot: false });
|
||||
])('falls back to built-in defaults at boot on invalid settings: %s', async (_label, backendsRow, categoriesRow) => {
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
if (backendsRow !== undefined) setSetting('storage.backends', backendsRow);
|
||||
if (categoriesRow !== undefined) setSetting('storage.categories', categoriesRow);
|
||||
registry.onModuleInit();
|
||||
await registry.onModuleInit();
|
||||
|
||||
expect(registry.resolve('files').backendName).toBe('uploads-local');
|
||||
expect(registry.resolve('backups').backendName).toBe('backups-local');
|
||||
});
|
||||
|
||||
it('lastLoadError() is null on a clean boot', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('lastLoadError() is null on a clean boot', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
expect(registry.lastLoadError()).toBeNull();
|
||||
});
|
||||
|
||||
@@ -312,12 +313,12 @@ describe('StorageRegistryService settings', () => {
|
||||
['malformed JSON', 'not json at all', undefined],
|
||||
])(
|
||||
'lastLoadError() records the exact failure at boot and stays set (silent-fallback audit minor): %s',
|
||||
(_label, backendsRow, categoriesRow) => {
|
||||
async (_label, backendsRow, categoriesRow) => {
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = makeRegistry({ boot: false });
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
if (backendsRow !== undefined) setSetting('storage.backends', backendsRow);
|
||||
if (categoriesRow !== undefined) setSetting('storage.categories', categoriesRow);
|
||||
registry.onModuleInit();
|
||||
await registry.onModuleInit();
|
||||
|
||||
expect(registry.lastLoadError()).not.toBeNull();
|
||||
// Still resolves on built-in defaults — the fallback itself is unchanged,
|
||||
@@ -326,17 +327,17 @@ describe('StorageRegistryService settings', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('a malformed row never leaks a secret-looking token into lastLoadError() (admin-facing configError)', () => {
|
||||
it('a malformed row never leaks a secret-looking token into lastLoadError() (admin-facing configError)', async () => {
|
||||
// JSON.parse's own SyntaxError can echo a snippet of the raw input around
|
||||
// the failure position (Node 24 V8) — an unquoted value like this is
|
||||
// exactly the shape a corrupted/hand-edited secretAccessKey row takes.
|
||||
// lastLoadError() must never carry that snippet through to the
|
||||
// admin-facing configError banner.
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = makeRegistry({ boot: false });
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
const secretToken = 'AKIA_SUPER_SECRET_TOKEN_1234567890';
|
||||
setSetting('storage.backends', `{"secretAccessKey": ${secretToken}_undefined_broken}`);
|
||||
registry.onModuleInit();
|
||||
await registry.onModuleInit();
|
||||
|
||||
const err = registry.lastLoadError();
|
||||
expect(err).not.toBeNull();
|
||||
@@ -345,21 +346,21 @@ describe('StorageRegistryService settings', () => {
|
||||
expect(err).toContain("'storage.backends' contains malformed JSON");
|
||||
});
|
||||
|
||||
it('lastLoadError() clears on the next successful load/reload', () => {
|
||||
it('lastLoadError() clears on the next successful load/reload', async () => {
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = makeRegistry({ boot: false });
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
setSetting('storage.categories', 'garbage {');
|
||||
registry.onModuleInit();
|
||||
await registry.onModuleInit();
|
||||
expect(registry.lastLoadError()).not.toBeNull();
|
||||
|
||||
setSetting('storage.categories', '{}');
|
||||
registry.reload();
|
||||
await registry.reload();
|
||||
expect(registry.lastLoadError()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the last-good config (not defaults) when a reload() sees invalid settings', () => {
|
||||
it('keeps the last-good config (not defaults) when a reload() sees invalid settings', async () => {
|
||||
const nasRoot = makeTmpDir();
|
||||
const { registry } = makeRegistry({
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [
|
||||
{ name: 'nas-backups', type: 'local', options: { root: nasRoot } },
|
||||
{ name: 'backup-mirror', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas-backups'] } },
|
||||
@@ -369,7 +370,7 @@ describe('StorageRegistryService settings', () => {
|
||||
expect(registry.resolve('backups').backendName).toBe('backup-mirror');
|
||||
|
||||
setSetting('storage.categories', 'garbage {');
|
||||
registry.reload();
|
||||
await registry.reload();
|
||||
|
||||
// last-good, i.e. the mirror config — NOT the built-in defaults
|
||||
expect(registry.resolve('backups').backendName).toBe('backup-mirror');
|
||||
@@ -382,12 +383,12 @@ describe('StorageRegistryService settings', () => {
|
||||
|
||||
describe('StorageRegistryService reload', () => {
|
||||
it('swaps the map atomically: new resolves see the new instance, held refs stay usable', async () => {
|
||||
const { registry, setUploadsRoot } = makeRegistry();
|
||||
const { registry, setUploadsRoot } = await makeRegistry();
|
||||
const before = registry.resolve('files');
|
||||
await before.driver.put('files/pre-reload.bin', Readable.from('old root'));
|
||||
|
||||
setUploadsRoot(makeTmpDir());
|
||||
registry.reload();
|
||||
await registry.reload();
|
||||
|
||||
const after = registry.resolve('files');
|
||||
expect(after.driver).not.toBe(before.driver);
|
||||
@@ -400,7 +401,7 @@ describe('StorageRegistryService reload', () => {
|
||||
expect(Buffer.concat(chunks).toString()).toBe('old root');
|
||||
});
|
||||
|
||||
it('cleans spool leftovers at boot only, never on reload()', () => {
|
||||
it('cleans spool leftovers at boot only, never on reload()', async () => {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const stray = path.join(uploadsRoot, '.tmp', 'in-flight.part');
|
||||
|
||||
@@ -410,11 +411,11 @@ describe('StorageRegistryService reload', () => {
|
||||
// process may be spooling into the same tree — see LocalDriver.init).
|
||||
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
fs.utimesSync(stray, old, old);
|
||||
const { registry } = makeRegistry({ uploadsRoot });
|
||||
const { registry } = await makeRegistry({ uploadsRoot });
|
||||
expect(fs.existsSync(stray)).toBe(false); // boot reclaimed it
|
||||
|
||||
fs.writeFileSync(stray, 'in-flight upload');
|
||||
registry.reload();
|
||||
await registry.reload();
|
||||
expect(fs.existsSync(stray)).toBe(true); // reload must not touch it
|
||||
});
|
||||
});
|
||||
@@ -422,13 +423,13 @@ describe('StorageRegistryService reload', () => {
|
||||
// ── assignCategory ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('StorageRegistryService assignCategory', () => {
|
||||
it('REG-ASSIGN-001 throws on an unknown backend and persists nothing (belt-and-braces alongside the migration job\'s own guard)', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('REG-ASSIGN-001 throws on an unknown backend and persists nothing (belt-and-braces alongside the migration job\'s own guard)', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
const before = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
|
||||
expect(() => registry.assignCategory('files', 'ghost-backend')).toThrow(
|
||||
await expect(registry.assignCategory('files', 'ghost-backend')).rejects.toThrow(
|
||||
"cannot assign 'files' to unknown backend 'ghost-backend'",
|
||||
);
|
||||
|
||||
@@ -439,10 +440,10 @@ describe('StorageRegistryService assignCategory', () => {
|
||||
expect(registry.snapshot().categories.files.backend).toBe('uploads-local'); // unchanged
|
||||
});
|
||||
|
||||
it('REG-ASSIGN-002 assigns and persists when the backend exists', () => {
|
||||
const { registry } = makeRegistry({ backends: [{ name: 'dest-local', type: 'local', options: { root: makeTmpDir() } }] });
|
||||
it('REG-ASSIGN-002 assigns and persists when the backend exists', async () => {
|
||||
const { registry } = await makeRegistry({ backends: [{ name: 'dest-local', type: 'local', options: { root: makeTmpDir() } }] });
|
||||
|
||||
registry.assignCategory('files', 'dest-local');
|
||||
await registry.assignCategory('files', 'dest-local');
|
||||
|
||||
expect(registry.snapshot().categories.files.backend).toBe('dest-local');
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.categories'").get() as {
|
||||
@@ -451,15 +452,15 @@ describe('StorageRegistryService assignCategory', () => {
|
||||
expect((JSON.parse(row.value) as Record<string, string>).files).toBe('dest-local');
|
||||
});
|
||||
|
||||
it('REG-ASSIGN-003 bumps the shared optimistic-concurrency version counter by exactly one, in the same write (audit #7)', () => {
|
||||
const { registry } = makeRegistry({ backends: [{ name: 'dest-local', type: 'local', options: { root: makeTmpDir() } }] });
|
||||
expect(registry.currentConfigVersion()).toBe(0);
|
||||
it('REG-ASSIGN-003 bumps the shared optimistic-concurrency version counter by exactly one, in the same write (audit #7)', async () => {
|
||||
const { registry } = await makeRegistry({ backends: [{ name: 'dest-local', type: 'local', options: { root: makeTmpDir() } }] });
|
||||
expect(await registry.currentConfigVersion()).toBe(0);
|
||||
|
||||
registry.assignCategory('files', 'dest-local');
|
||||
expect(registry.currentConfigVersion()).toBe(1);
|
||||
await registry.assignCategory('files', 'dest-local');
|
||||
expect(await registry.currentConfigVersion()).toBe(1);
|
||||
|
||||
registry.assignCategory('journey', 'dest-local');
|
||||
expect(registry.currentConfigVersion()).toBe(2);
|
||||
await registry.assignCategory('journey', 'dest-local');
|
||||
expect(await registry.currentConfigVersion()).toBe(2);
|
||||
|
||||
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.config_version'").get() as
|
||||
| { value: string }
|
||||
@@ -467,14 +468,14 @@ describe('StorageRegistryService assignCategory', () => {
|
||||
expect(row?.value).toBe('2');
|
||||
});
|
||||
|
||||
it('REG-ASSIGN-004 an unknown-backend refusal bumps nothing — the version write shares the categories transaction', () => {
|
||||
const { registry } = makeRegistry();
|
||||
expect(() => registry.assignCategory('files', 'ghost-backend')).toThrow();
|
||||
expect(registry.currentConfigVersion()).toBe(0);
|
||||
it('REG-ASSIGN-004 an unknown-backend refusal bumps nothing — the version write shares the categories transaction', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
await expect(registry.assignCategory('files', 'ghost-backend')).rejects.toThrow();
|
||||
expect(await registry.currentConfigVersion()).toBe(0);
|
||||
});
|
||||
|
||||
it('REG-ASSIGN-005 refuses the migration flip onto a backend that is currently a mirror replica (validateConfig, other direction)', () => {
|
||||
const { registry } = makeRegistry({
|
||||
it('REG-ASSIGN-005 refuses the migration flip onto a backend that is currently a mirror replica (validateConfig, other direction)', async () => {
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [
|
||||
{ name: 'nas-backups', type: 'local', options: { root: makeTmpDir() } },
|
||||
{ name: 'backups-mirror', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas-backups'] } },
|
||||
@@ -482,15 +483,15 @@ describe('StorageRegistryService assignCategory', () => {
|
||||
categories: { backups: 'backups-mirror' },
|
||||
});
|
||||
|
||||
expect(() => registry.assignCategory('files', 'nas-backups')).toThrow(
|
||||
await expect(registry.assignCategory('files', 'nas-backups')).rejects.toThrow(
|
||||
/cannot assign 'files' to 'nas-backups' — 'nas-backups' is a mirror replica of 'backups-mirror'/,
|
||||
);
|
||||
expect(registry.snapshot().categories.files.backend).toBe('uploads-local'); // unchanged
|
||||
expect(registry.currentConfigVersion()).toBe(0); // nothing written
|
||||
expect(await registry.currentConfigVersion()).toBe(0); // nothing written
|
||||
});
|
||||
|
||||
it('REG-ASSIGN-006 refuses the flip onto a MIRROR whose primary is a replica of another mirror', () => {
|
||||
const { registry } = makeRegistry({
|
||||
it('REG-ASSIGN-006 refuses the flip onto a MIRROR whose primary is a replica of another mirror', async () => {
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: makeTmpDir() } },
|
||||
{ name: 'extra', type: 'local', options: { root: makeTmpDir() } },
|
||||
@@ -500,18 +501,18 @@ describe('StorageRegistryService assignCategory', () => {
|
||||
categories: { backups: 'backups-mirror' },
|
||||
});
|
||||
|
||||
expect(() => registry.assignCategory('files', 'nas-mirror')).toThrow(
|
||||
await expect(registry.assignCategory('files', 'nas-mirror')).rejects.toThrow(
|
||||
/cannot assign 'files' to 'nas-mirror' — 'nas' is a mirror replica of 'backups-mirror'/,
|
||||
);
|
||||
expect(registry.currentConfigVersion()).toBe(0);
|
||||
expect(await registry.currentConfigVersion()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── replica-failure health ────────────────────────────────────────────────────
|
||||
|
||||
describe('StorageRegistryService replica health', () => {
|
||||
it('keeps a bounded ring of replica failures (last 50)', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('keeps a bounded ring of replica failures (last 50)', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
registry.recordReplicaFailure({
|
||||
backend: 'nas-backups',
|
||||
@@ -542,8 +543,8 @@ describe('settings-declared s3 backends', () => {
|
||||
timeoutMs: 30000,
|
||||
};
|
||||
|
||||
it('accepts a settings s3 backend and assigns it to a category', () => {
|
||||
const { registry } = makeRegistry({
|
||||
it('accepts a settings s3 backend and assigns it to a category', async () => {
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [{ name: 'off-box', type: 's3', options: s3Options }],
|
||||
categories: { covers: 'off-box' },
|
||||
});
|
||||
@@ -553,26 +554,26 @@ describe('settings-declared s3 backends', () => {
|
||||
expect(resolved.keyPrefix).toBe('covers/');
|
||||
});
|
||||
|
||||
it('decrypts an enc:v1: secretAccessKey at build (decrypt-at-build)', () => {
|
||||
const { registry } = makeRegistry({
|
||||
it('decrypts an enc:v1: secretAccessKey at build (decrypt-at-build)', async () => {
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [{ name: 'off-box', type: 's3', options: { ...s3Options, secretAccessKey: encrypt_api_key('sk-secret') } }],
|
||||
categories: { backups: 'off-box' },
|
||||
});
|
||||
expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver);
|
||||
});
|
||||
|
||||
it('applies the shared-schema defaults for omitted optional fields', () => {
|
||||
it('applies the shared-schema defaults for omitted optional fields', async () => {
|
||||
const { endpoint, bucket, accessKeyId, secretAccessKey } = s3Options;
|
||||
const { registry } = makeRegistry({
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [{ name: 'off-box', type: 's3', options: { endpoint, bucket, accessKeyId, secretAccessKey } }],
|
||||
categories: { backups: 'off-box' },
|
||||
});
|
||||
expect(registry.resolve('backups').backendName).toBe('off-box');
|
||||
});
|
||||
|
||||
it('serves as a mirror replica (local primary + settings s3 replica on backups)', () => {
|
||||
it('serves as a mirror replica (local primary + settings s3 replica on backups)', async () => {
|
||||
const nasRoot = makeTmpDir();
|
||||
const { registry } = makeRegistry({
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [
|
||||
{ name: 'off-box', type: 's3', options: s3Options },
|
||||
{ name: 'nas-backups', type: 'local', options: { root: nasRoot } },
|
||||
@@ -584,9 +585,9 @@ describe('settings-declared s3 backends', () => {
|
||||
expect(registry.resolve('backups').driver).toBeInstanceOf(MirrorDriver);
|
||||
});
|
||||
|
||||
it('falls back with the pinned message when s3 options fail the shared schema', () => {
|
||||
it('falls back with the pinned message when s3 options fail the shared schema', async () => {
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = makeRegistry({
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [{ name: 'off-box', type: 's3', options: { ...s3Options, endpoint: 'not a url' } }],
|
||||
});
|
||||
expect(registry.resolve('backups').backendName).toBe('backups-local');
|
||||
@@ -594,9 +595,9 @@ describe('settings-declared s3 backends', () => {
|
||||
expect(logged).toContain("s3 backend 'off-box' has invalid options — endpoint:");
|
||||
});
|
||||
|
||||
it('falls back with the pinned message when the ciphertext cannot be decrypted', () => {
|
||||
it('falls back with the pinned message when the ciphertext cannot be decrypted', async () => {
|
||||
const errorSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = makeRegistry({
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [{ name: 'off-box', type: 's3', options: { ...s3Options, secretAccessKey: 'enc:v1:AAAA' } }],
|
||||
categories: { backups: 'off-box' },
|
||||
});
|
||||
@@ -609,8 +610,8 @@ describe('settings-declared s3 backends', () => {
|
||||
// ── preview() — admin writes never take the silent fallback ──────────────────
|
||||
|
||||
describe('preview()', () => {
|
||||
it('throws the exact registry error for an invalid candidate and mutates nothing', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('throws the exact registry error for an invalid candidate and mutates nothing', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
const before = registry.resolve('backups').driver;
|
||||
expect(() =>
|
||||
registry.preview({ backends: [], categories: { backups: 'nope' } }),
|
||||
@@ -618,9 +619,9 @@ describe('preview()', () => {
|
||||
expect(registry.resolve('backups').driver).toBe(before); // same instance — state untouched
|
||||
});
|
||||
|
||||
it('accepts a valid candidate without changing resolution', () => {
|
||||
it('accepts a valid candidate without changing resolution', async () => {
|
||||
const nasRoot = makeTmpDir();
|
||||
const { registry } = makeRegistry();
|
||||
const { registry } = await makeRegistry();
|
||||
const before = registry.resolve('backups').driver;
|
||||
registry.preview({
|
||||
backends: [{ name: 'nas-backups', type: 'local', options: { root: nasRoot } }],
|
||||
@@ -640,8 +641,8 @@ describe('preview()', () => {
|
||||
// sweep itself is correct; the fix is that no config can express the setup.
|
||||
|
||||
describe('StorageRegistryService shared-replica refusals', () => {
|
||||
it('REG-SHARED-001 refuses the config that would let a backups sync sweep another category\'s objects (uploads-local as a backups-mirror replica)', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('REG-SHARED-001 refuses the config that would let a backups sync sweep another category\'s objects (uploads-local as a backups-mirror replica)', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
expect(() =>
|
||||
registry.preview({
|
||||
backends: [
|
||||
@@ -653,8 +654,8 @@ describe('StorageRegistryService shared-replica refusals', () => {
|
||||
).toThrow(/backend 'uploads-local' is a mirror replica of 'backups-mirror' and also serves category/);
|
||||
});
|
||||
|
||||
it('REG-SHARED-002 refuses a replica that serves a category as ANOTHER mirror\'s primary', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('REG-SHARED-002 refuses a replica that serves a category as ANOTHER mirror\'s primary', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
expect(() =>
|
||||
registry.preview({
|
||||
backends: [
|
||||
@@ -668,8 +669,8 @@ describe('StorageRegistryService shared-replica refusals', () => {
|
||||
).toThrow(/backend 'nas' is a mirror replica of 'backups-mirror' and also serves category 'files'/);
|
||||
});
|
||||
|
||||
it('REG-SHARED-003 refuses one backend replicating two mirrors whose swept prefixes overlap (\'\' overlaps everything)', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('REG-SHARED-003 refuses one backend replicating two mirrors whose swept prefixes overlap (\'\' overlaps everything)', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
expect(() =>
|
||||
registry.preview({
|
||||
backends: [
|
||||
@@ -684,8 +685,8 @@ describe('StorageRegistryService shared-replica refusals', () => {
|
||||
).toThrow(/backend 'nas' replicates both 'm-backups' and 'm-files', whose swept key prefixes overlap/);
|
||||
});
|
||||
|
||||
it('REG-SHARED-004 accepts one backend replicating two mirrors with DISJOINT swept prefixes', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('REG-SHARED-004 accepts one backend replicating two mirrors with DISJOINT swept prefixes', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
expect(() =>
|
||||
registry.preview({
|
||||
backends: [
|
||||
@@ -700,8 +701,8 @@ describe('StorageRegistryService shared-replica refusals', () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('REG-SHARED-005 still accepts the ordinary shape: a dedicated, unassigned replica', () => {
|
||||
const { registry } = makeRegistry();
|
||||
it('REG-SHARED-005 still accepts the ordinary shape: a dedicated, unassigned replica', async () => {
|
||||
const { registry } = await makeRegistry();
|
||||
expect(() =>
|
||||
registry.preview({
|
||||
backends: [
|
||||
@@ -725,9 +726,9 @@ describe('seed-once storage-config.json import', () => {
|
||||
}
|
||||
|
||||
/** A registry with NO storage.* rows (makeRegistry seeds an override row, so build raw). */
|
||||
function makeUnseededRegistry() {
|
||||
async function makeUnseededRegistry() {
|
||||
const stub = makeEnvStub({});
|
||||
return new StorageRegistryService(db, stub.env, new StorageEventsService());
|
||||
return new StorageRegistryService(db, stub.env, new StorageEventsService(), await createTestUnitOfWork(testDb));
|
||||
}
|
||||
|
||||
function readRow(key: string): string | undefined {
|
||||
@@ -741,7 +742,7 @@ describe('seed-once storage-config.json import', () => {
|
||||
fs.rmSync(SEED_CONFIG_PATH, { force: true });
|
||||
});
|
||||
|
||||
it('SEED-001 imports a valid file when no rows exist: encrypted rows, live drivers, pinned log', () => {
|
||||
it('SEED-001 imports a valid file when no rows exist: encrypted rows, live drivers, pinned log', async () => {
|
||||
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
const root = nasRoot();
|
||||
writeSeed(
|
||||
@@ -762,8 +763,8 @@ describe('seed-once storage-config.json import', () => {
|
||||
categories: { backups: 'off-box' },
|
||||
}),
|
||||
);
|
||||
const registry = makeUnseededRegistry();
|
||||
registry.onModuleInit();
|
||||
const registry = await makeUnseededRegistry();
|
||||
await registry.onModuleInit();
|
||||
|
||||
expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver);
|
||||
const storedBackends = JSON.parse(readRow('storage.backends')!) as Array<{
|
||||
@@ -778,41 +779,41 @@ describe('seed-once storage-config.json import', () => {
|
||||
expect(logged).toContain('the file is now ignored; manage storage in the admin UI');
|
||||
});
|
||||
|
||||
it('SEED-002 rows exist → file not read, pinned ignore log, rows unchanged', () => {
|
||||
it('SEED-002 rows exist → file not read, pinned ignore log, rows unchanged', async () => {
|
||||
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
writeSeed('{ this would explode if parsed');
|
||||
const { registry } = makeRegistry(); // helper writes the uploads override row
|
||||
const { registry } = await makeRegistry(); // helper writes the uploads override row
|
||||
expect(registry.resolve('files').backendName).toBe('uploads-local');
|
||||
const logged = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(logged).toContain('storage config rows exist — ignoring');
|
||||
});
|
||||
|
||||
it('SEED-003 no rows, no file → boots on built-in defaults (no seed side effects)', () => {
|
||||
const registry = makeUnseededRegistry();
|
||||
registry.onModuleInit();
|
||||
it('SEED-003 no rows, no file → boots on built-in defaults (no seed side effects)', async () => {
|
||||
const registry = await makeUnseededRegistry();
|
||||
await registry.onModuleInit();
|
||||
expect(registry.resolve('backups').backendName).toBe('backups-local');
|
||||
expect(readRow('storage.backends')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('SEED-004 unparseable JSON aborts boot with the exact error', () => {
|
||||
it('SEED-004 unparseable JSON aborts boot with the exact error', async () => {
|
||||
writeSeed('not json at all');
|
||||
const registry = makeUnseededRegistry();
|
||||
expect(() => registry.onModuleInit()).toThrow(/invalid storage seed file .*storage-config\.json: /);
|
||||
const registry = await makeUnseededRegistry();
|
||||
await expect(registry.onModuleInit()).rejects.toThrow(/invalid storage seed file .*storage-config\.json: /);
|
||||
});
|
||||
|
||||
it('SEED-005 schema violation aborts boot — including the reserved readOnly key', () => {
|
||||
it('SEED-005 schema violation aborts boot — including the reserved readOnly key', async () => {
|
||||
writeSeed(JSON.stringify({ backends: [], categories: {}, readOnly: true }));
|
||||
expect(() => makeUnseededRegistry().onModuleInit()).toThrow(/invalid storage seed file/);
|
||||
await expect((await makeUnseededRegistry()).onModuleInit()).rejects.toThrow(/invalid storage seed file/);
|
||||
});
|
||||
|
||||
it('SEED-006 semantic violation aborts boot with the registry error verbatim', () => {
|
||||
it('SEED-006 semantic violation aborts boot with the registry error verbatim', async () => {
|
||||
writeSeed(JSON.stringify({ backends: [], categories: { backups: 'nope' } }));
|
||||
expect(() => makeUnseededRegistry().onModuleInit()).toThrow(
|
||||
await expect((await makeUnseededRegistry()).onModuleInit()).rejects.toThrow(
|
||||
/category 'backups' maps to unknown backend 'nope'/,
|
||||
);
|
||||
});
|
||||
|
||||
it('SEED-007 a plaintext-secret seed imports without an explicit ENCRYPTION_KEY and encrypts at rest', () => {
|
||||
it('SEED-007 a plaintext-secret seed imports without an explicit ENCRYPTION_KEY and encrypts at rest', async () => {
|
||||
writeSeed(
|
||||
JSON.stringify({
|
||||
backends: [
|
||||
@@ -830,8 +831,8 @@ describe('seed-once storage-config.json import', () => {
|
||||
categories: {},
|
||||
}),
|
||||
);
|
||||
const registry = makeUnseededRegistry();
|
||||
registry.onModuleInit();
|
||||
const registry = await makeUnseededRegistry();
|
||||
await registry.onModuleInit();
|
||||
// No key-presence gate: the implicit key covers encryption when
|
||||
// ENCRYPTION_KEY is unset, and the plaintext never persists.
|
||||
const row = readRow('storage.backends')!;
|
||||
@@ -839,7 +840,7 @@ describe('seed-once storage-config.json import', () => {
|
||||
expect(row).toContain('enc:v1:');
|
||||
});
|
||||
|
||||
it('SEED-008 a mask sentinel in the seed file aborts boot', () => {
|
||||
it('SEED-008 a mask sentinel in the seed file aborts boot', async () => {
|
||||
writeSeed(
|
||||
JSON.stringify({
|
||||
backends: [
|
||||
@@ -857,23 +858,23 @@ describe('seed-once storage-config.json import', () => {
|
||||
categories: {},
|
||||
}),
|
||||
);
|
||||
expect(() => makeUnseededRegistry().onModuleInit()).toThrow(/mask/);
|
||||
await expect((await makeUnseededRegistry()).onModuleInit()).rejects.toThrow(/mask/);
|
||||
});
|
||||
|
||||
it('SEED-009 a second boot after a successful seed ignores the file (seed-once)', () => {
|
||||
it('SEED-009 a second boot after a successful seed ignores the file (seed-once)', async () => {
|
||||
const root = nasRoot();
|
||||
writeSeed(JSON.stringify({ backends: [{ name: 'nas', type: 'local', options: { root } }], categories: { backups: 'nas' } }));
|
||||
makeUnseededRegistry().onModuleInit();
|
||||
await (await makeUnseededRegistry()).onModuleInit();
|
||||
const firstRow = readRow('storage.backends');
|
||||
|
||||
writeSeed(JSON.stringify({ backends: [], categories: { backups: 'other' } })); // would fail preview if read
|
||||
const second = makeUnseededRegistry();
|
||||
second.onModuleInit(); // must not throw — rows exist, file ignored
|
||||
const second = await makeUnseededRegistry();
|
||||
await second.onModuleInit(); // must not throw — rows exist, file ignored
|
||||
expect(readRow('storage.backends')).toBe(firstRow);
|
||||
expect(second.resolve('backups').backendName).toBe('nas');
|
||||
});
|
||||
|
||||
it('SEED-010 an all-encrypted seed imports and keeps its ciphertext byte-for-byte', () => {
|
||||
it('SEED-010 an all-encrypted seed imports and keeps its ciphertext byte-for-byte', async () => {
|
||||
const cipher = encrypt_api_key('sk-seed');
|
||||
writeSeed(
|
||||
JSON.stringify({
|
||||
@@ -892,8 +893,8 @@ describe('seed-once storage-config.json import', () => {
|
||||
categories: { backups: 'off-box' },
|
||||
}),
|
||||
);
|
||||
const registry = makeUnseededRegistry();
|
||||
registry.onModuleInit();
|
||||
const registry = await makeUnseededRegistry();
|
||||
await registry.onModuleInit();
|
||||
// enc:v1: values pass through the idempotent encrypt — the ciphertext
|
||||
// must persist byte-for-byte.
|
||||
expect(registry.resolve('backups').driver).toBeInstanceOf(S3Driver);
|
||||
@@ -904,10 +905,10 @@ describe('seed-once storage-config.json import', () => {
|
||||
// ── snapshot() — effective world with provenance ──────────────────────────────
|
||||
|
||||
describe('snapshot()', () => {
|
||||
it('reports built-ins, env backends, and settings entries with their sources', () => {
|
||||
it('reports built-ins, env backends, and settings entries with their sources', async () => {
|
||||
const photoDir = makeTmpDir();
|
||||
const nasRoot = makeTmpDir();
|
||||
const { registry, uploadsRoot } = makeRegistry({
|
||||
const { registry, uploadsRoot } = await makeRegistry({
|
||||
placePhotoDir: photoDir,
|
||||
backends: [{ name: 'nas-backups', type: 'local', options: { root: nasRoot } }],
|
||||
categories: { backups: 'nas-backups' },
|
||||
@@ -927,10 +928,10 @@ describe('snapshot()', () => {
|
||||
expect(Object.keys(snap.categories).sort()).toEqual([...STORAGE_CATEGORIES].sort());
|
||||
});
|
||||
|
||||
it('reports pure built-in defaults when no settings rows exist', () => {
|
||||
const { registry } = makeRegistry({ boot: false });
|
||||
it('reports pure built-in defaults when no settings rows exist', async () => {
|
||||
const { registry } = await makeRegistry({ boot: false });
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'storage.backends'").run();
|
||||
registry.onModuleInit();
|
||||
await registry.onModuleInit();
|
||||
const snap = registry.snapshot();
|
||||
expect(snap.backends.map((b) => [b.name, b.source])).toEqual([
|
||||
['uploads-local', 'built-in'],
|
||||
@@ -938,9 +939,9 @@ describe('snapshot()', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps encrypted secrets encrypted in the snapshot (never plaintext)', () => {
|
||||
it('keeps encrypted secrets encrypted in the snapshot (never plaintext)', async () => {
|
||||
const cipher = encrypt_api_key('sk-secret');
|
||||
const { registry } = makeRegistry({
|
||||
const { registry } = await makeRegistry({
|
||||
backends: [
|
||||
{
|
||||
name: 'off-box',
|
||||
@@ -960,12 +961,12 @@ describe('snapshot()', () => {
|
||||
expect(offBox.options).toMatchObject({ region: 'us-east-1', retries: 1 }); // schema defaults visible
|
||||
});
|
||||
|
||||
it('shows the last-good world after an invalid reload (snapshot = live state)', () => {
|
||||
it('shows the last-good world after an invalid reload (snapshot = live state)', async () => {
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
const { registry } = makeRegistry();
|
||||
const { registry } = await makeRegistry();
|
||||
const before = registry.snapshot();
|
||||
setSetting('storage.categories', 'garbage {');
|
||||
registry.reload();
|
||||
await registry.reload();
|
||||
expect(registry.snapshot()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { StorageEventsService } from '../../../../src/nest/storage/storage-event
|
||||
import { StorageRegistryService } from '../../../../src/nest/storage/storage-registry.service';
|
||||
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
||||
import { StatsBusyError, StorageStatsService } from '../../../../src/nest/storage/storage-stats.service';
|
||||
import { createTestUnitOfWork } from '../../../helpers/test-uow';
|
||||
|
||||
const db = new DatabaseService(testDb);
|
||||
|
||||
@@ -47,7 +48,7 @@ afterEach(() => {
|
||||
while (tmpDirs.length) fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeWorld() {
|
||||
async function makeWorld() {
|
||||
const uploadsRoot = makeTmpDir();
|
||||
const backupsRoot = makeTmpDir();
|
||||
setSetting(
|
||||
@@ -58,8 +59,8 @@ function makeWorld() {
|
||||
]),
|
||||
);
|
||||
const env = { env: () => ({ paths: {} }) } as unknown as RuntimeEnvService;
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService());
|
||||
registry.onModuleInit();
|
||||
const registry = new StorageRegistryService(db, env, new StorageEventsService(), await createTestUnitOfWork(testDb));
|
||||
await registry.onModuleInit();
|
||||
const storage = new StorageService(registry);
|
||||
const stats = new StorageStatsService(storage, db);
|
||||
return { storage, stats, uploadsRoot };
|
||||
@@ -67,7 +68,7 @@ function makeWorld() {
|
||||
|
||||
describe('StorageStatsService', () => {
|
||||
it('STATS-001 sums objects and bytes per category, legacy photos separate, and persists with computedAt', async () => {
|
||||
const { storage, stats } = makeWorld();
|
||||
const { storage, stats } = await makeWorld();
|
||||
await storage.put('files', 'a.pdf', Readable.from('12345')); // 5 bytes
|
||||
await storage.put('files', 'b.pdf', Readable.from('123')); // 3 bytes
|
||||
await storage.put('covers', 'c.jpg', Readable.from('1234567')); // 7 bytes
|
||||
@@ -79,13 +80,13 @@ describe('StorageStatsService', () => {
|
||||
expect(usage.legacyPhotos).toEqual({ objects: 1, bytes: 2 });
|
||||
expect(usage.computedAt).toBeGreaterThan(0);
|
||||
// Persisted round-trip:
|
||||
expect(stats.readUsage()).toEqual(usage);
|
||||
expect(await stats.readUsage()).toEqual(usage);
|
||||
const raw = testDb.prepare("SELECT value FROM app_settings WHERE key = 'storage.usage'").get() as { value: string };
|
||||
expect(JSON.parse(raw.value)).toEqual(usage);
|
||||
});
|
||||
|
||||
it('STATS-002 photos-google/photos-trek nested content is NOT double-counted into legacy photos', async () => {
|
||||
const { storage, stats } = makeWorld();
|
||||
const { storage, stats } = await makeWorld();
|
||||
await storage.put('photos-google', 'g.jpg', Readable.from('gggg'));
|
||||
const usage = await stats.scan();
|
||||
expect(usage.categories['photos-google']).toEqual({ objects: 1, bytes: 4 });
|
||||
@@ -93,17 +94,17 @@ describe('StorageStatsService', () => {
|
||||
});
|
||||
|
||||
it('STATS-003 concurrent scans throw StatsBusyError', async () => {
|
||||
const { storage, stats } = makeWorld();
|
||||
const { storage, stats } = await makeWorld();
|
||||
for (let i = 0; i < 30; i++) await storage.put('files', `f${i}.bin`, Readable.from('x'.repeat(500)));
|
||||
const first = stats.scan();
|
||||
await expect(stats.scan()).rejects.toThrow(StatsBusyError);
|
||||
await first;
|
||||
});
|
||||
|
||||
it('STATS-004 readUsage returns null on absent or unparseable rows', () => {
|
||||
const { stats } = makeWorld();
|
||||
expect(stats.readUsage()).toBeNull();
|
||||
it('STATS-004 readUsage returns null on absent or unparseable rows', async () => {
|
||||
const { stats } = await makeWorld();
|
||||
expect(await stats.readUsage()).toBeNull();
|
||||
setSetting('storage.usage', 'not json');
|
||||
expect(stats.readUsage()).toBeNull();
|
||||
expect(await stats.readUsage()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,17 +25,17 @@ function statsStub(): StorageStatsService {
|
||||
beforeEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('StorageUsageScanJob', () => {
|
||||
it('SCAN-001 registers the nightly 04:15 cron when the registrar gate is open', () => {
|
||||
it('SCAN-001 registers the nightly 04:15 cron when the registrar gate is open', async () => {
|
||||
const registrar = registrarStub();
|
||||
const job = new StorageUsageScanJob(registrar as unknown as CronRegistrarService, statsStub());
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).toHaveBeenCalledWith('storage-usage-scan', '15 4 * * *', expect.any(Function));
|
||||
});
|
||||
|
||||
it('SCAN-002 does not register under the test gate (registrar.isEnabled() false)', () => {
|
||||
it('SCAN-002 does not register under the test gate (registrar.isEnabled() false)', async () => {
|
||||
const registrar = registrarStub(false);
|
||||
const job = new StorageUsageScanJob(registrar as unknown as CronRegistrarService, statsStub());
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
expect(registrar.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('StorageUsageScanJob', () => {
|
||||
const registrar = registrarStub();
|
||||
const stats = statsStub();
|
||||
const job = new StorageUsageScanJob(registrar as unknown as CronRegistrarService, stats);
|
||||
job.onApplicationBootstrap();
|
||||
await job.onApplicationBootstrap();
|
||||
const onTick = registrar.register.mock.calls[0]![2] as () => void;
|
||||
onTick();
|
||||
await vi.waitFor(() => expect(stats.scan).toHaveBeenCalledTimes(1));
|
||||
|
||||
@@ -16,9 +16,9 @@ function makeService(overrides: Partial<TodoService> = {}): TodoService {
|
||||
} as unknown as TodoService;
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
|
||||
try {
|
||||
fn();
|
||||
await fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
@@ -31,9 +31,9 @@ function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
// trip-access.guard.test.ts with the check itself.
|
||||
describe('TodoController (parity with the legacy /api/trips/:tripId/todo route)', () => {
|
||||
|
||||
it('GET / returns items', () => {
|
||||
const svc = makeService({ listItems: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
|
||||
it('GET / returns items', async () => {
|
||||
const svc = makeService({ listItems: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial<TodoService>);
|
||||
expect(await new TodoController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
@@ -43,75 +43,75 @@ describe('TodoController (parity with the legacy /api/trips/:tripId/todo route)'
|
||||
// bypass parameter pipes, so that path is covered by the e2e suite and
|
||||
// the schema spec in @trek/shared.
|
||||
|
||||
it('creates and broadcasts', () => {
|
||||
const createItem = vi.fn().mockReturnValue({ id: 9, name: 'Pack' });
|
||||
it('creates and broadcasts', async () => {
|
||||
const createItem = vi.fn().mockResolvedValue({ id: 9, name: 'Pack' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ createItem, broadcast } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).create(user, '5', { name: 'Pack', priority: 2 }, 'sock')).toEqual({ item: { id: 9, name: 'Pack' } });
|
||||
expect(await new TodoController(svc).create(user, '5', { name: 'Pack', priority: 2 }, 'sock')).toEqual({ item: { id: 9, name: 'Pack' } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'todo:created', { item: { id: 9, name: 'Pack' } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when item missing', () => {
|
||||
const svc = makeService({ updateItem: vi.fn().mockReturnValue(null) } as Partial<TodoService>);
|
||||
expect(thrown(() => new TodoController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
|
||||
it('404 when item missing', async () => {
|
||||
const svc = makeService({ updateItem: vi.fn().mockResolvedValue(null) } as Partial<TodoService>);
|
||||
expect(await thrown(() => new TodoController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
|
||||
status: 404, body: { error: 'Item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates, forwards changed keys, normalizes checked to 0/1, broadcasts', () => {
|
||||
const updateItem = vi.fn().mockReturnValue({ id: 9 });
|
||||
it('updates, forwards changed keys, normalizes checked to 0/1, broadcasts', async () => {
|
||||
const updateItem = vi.fn().mockResolvedValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateItem, broadcast } as Partial<TodoService>);
|
||||
new TodoController(svc).update(user, '5', '9', { checked: true }, 'sock');
|
||||
await new TodoController(svc).update(user, '5', '9', { checked: true }, 'sock');
|
||||
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ checked: 1 }), ['checked']);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'todo:updated', { item: { id: 9 } }, 'sock');
|
||||
});
|
||||
|
||||
it('normalizes checked false to 0', () => {
|
||||
const updateItem = vi.fn().mockReturnValue({ id: 9 });
|
||||
it('normalizes checked false to 0', async () => {
|
||||
const updateItem = vi.fn().mockResolvedValue({ id: 9 });
|
||||
const svc = makeService({ updateItem, broadcast: vi.fn() } as Partial<TodoService>);
|
||||
new TodoController(svc).update(user, '5', '9', { checked: false });
|
||||
await new TodoController(svc).update(user, '5', '9', { checked: false });
|
||||
expect(updateItem).toHaveBeenCalledWith('5', '9', expect.objectContaining({ checked: 0 }), ['checked']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('404 when item missing', () => {
|
||||
const svc = makeService({ deleteItem: vi.fn().mockReturnValue(false) } as Partial<TodoService>);
|
||||
expect(thrown(() => new TodoController(svc).remove(user, '5', '9'))).toEqual({
|
||||
it('404 when item missing', async () => {
|
||||
const svc = makeService({ deleteItem: vi.fn().mockResolvedValue(false) } as Partial<TodoService>);
|
||||
expect(await thrown(() => new TodoController(svc).remove(user, '5', '9'))).toEqual({
|
||||
status: 404, body: { error: 'Item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes and broadcasts', () => {
|
||||
const deleteItem = vi.fn().mockReturnValue(true);
|
||||
it('deletes and broadcasts', async () => {
|
||||
const deleteItem = vi.fn().mockResolvedValue(true);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ deleteItem, broadcast } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(await new TodoController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'todo:deleted', { itemId: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /reorder succeeds with permission', () => {
|
||||
const reorderItems = vi.fn();
|
||||
it('PUT /reorder succeeds with permission', async () => {
|
||||
const reorderItems = vi.fn().mockResolvedValue(undefined);
|
||||
const svc = makeService({ reorderItems } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
|
||||
expect(await new TodoController(svc).reorder(user, '5', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
|
||||
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1, 2]);
|
||||
});
|
||||
|
||||
describe('category assignees', () => {
|
||||
it('GET returns assignees', () => {
|
||||
const svc = makeService({ getCategoryAssignees: vi.fn().mockReturnValue([{ user_id: 2 }]) } as Partial<TodoService>);
|
||||
expect(new TodoController(svc).categoryAssignees(user, '5')).toEqual({ assignees: [{ user_id: 2 }] });
|
||||
it('GET returns assignees', async () => {
|
||||
const svc = makeService({ getCategoryAssignees: vi.fn().mockResolvedValue([{ user_id: 2 }]) } as Partial<TodoService>);
|
||||
expect(await new TodoController(svc).categoryAssignees(user, '5')).toEqual({ assignees: [{ user_id: 2 }] });
|
||||
});
|
||||
|
||||
it('PUT updates, decodes the category and broadcasts', () => {
|
||||
const updateCategoryAssignees = vi.fn().mockReturnValue([{ user_id: 2 }]);
|
||||
it('PUT updates, decodes the category and broadcasts', async () => {
|
||||
const updateCategoryAssignees = vi.fn().mockResolvedValue([{ user_id: 2 }]);
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateCategoryAssignees, broadcast } as Partial<TodoService>);
|
||||
new TodoController(svc).updateCategoryAssignees(user, '5', 'To%20Buy', { user_ids: [2] }, 'sock');
|
||||
await new TodoController(svc).updateCategoryAssignees(user, '5', 'To%20Buy', { user_ids: [2] }, 'sock');
|
||||
expect(updateCategoryAssignees).toHaveBeenCalledWith('5', 'To Buy', [2]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'todo:assignees', { category: 'To Buy', assignees: [{ user_id: 2 }] }, 'sock');
|
||||
});
|
||||
|
||||
@@ -53,7 +53,8 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
|
||||
let svc: TodoService;
|
||||
beforeAll(async () => {
|
||||
svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), await createTestUnitOfWork(testDb)), new RealtimeService());
|
||||
const uow = await createTestUnitOfWork(testDb);
|
||||
svc = new TodoService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb), uow), new RealtimeService(), uow);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -72,27 +73,27 @@ afterAll(() => {
|
||||
// ── verifyTripAccess ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('verifyTripAccess', () => {
|
||||
it('TODO-SVC-001: returns trip for owner', () => {
|
||||
it('TODO-SVC-001: returns trip for owner', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const result = svc.verifyTripAccess(trip.id, user.id);
|
||||
const result = await svc.verifyTripAccess(trip.id, user.id);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe(trip.id);
|
||||
});
|
||||
|
||||
it('TODO-SVC-002: returns null for non-member', () => {
|
||||
it('TODO-SVC-002: returns null for non-member', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
expect(svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
|
||||
expect(await svc.verifyTripAccess(trip.id, stranger.id)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('TODO-SVC-003: returns trip for member', () => {
|
||||
it('TODO-SVC-003: returns trip for member', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
const result = svc.verifyTripAccess(trip.id, member.id);
|
||||
const result = await svc.verifyTripAccess(trip.id, member.id);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -100,16 +101,16 @@ describe('verifyTripAccess', () => {
|
||||
// ── listItems / createItem ────────────────────────────────────────────────────
|
||||
|
||||
describe('listItems and createItem', () => {
|
||||
it('TODO-SVC-004: listItems returns empty array for new trip', () => {
|
||||
it('TODO-SVC-004: listItems returns empty array for new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(svc.listItems(trip.id)).toEqual([]);
|
||||
expect(await svc.listItems(trip.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('TODO-SVC-005: createItem inserts a todo with name only', () => {
|
||||
it('TODO-SVC-005: createItem inserts a todo with name only', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = svc.createItem(trip.id, { name: 'Buy snacks' }) as any;
|
||||
const item = (await svc.createItem(trip.id, { name: 'Buy snacks' })) as any;
|
||||
expect(item).toBeDefined();
|
||||
expect(item.name).toBe('Buy snacks');
|
||||
expect(item.checked).toBe(0);
|
||||
@@ -117,35 +118,35 @@ describe('listItems and createItem', () => {
|
||||
expect(item.sort_order).toBe(0);
|
||||
});
|
||||
|
||||
it('TODO-SVC-006: createItem assigns incrementing sort_order', () => {
|
||||
it('TODO-SVC-006: createItem assigns incrementing sort_order', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = svc.createItem(trip.id, { name: 'A' }) as any;
|
||||
const b = svc.createItem(trip.id, { name: 'B' }) as any;
|
||||
const a = (await svc.createItem(trip.id, { name: 'A' })) as any;
|
||||
const b = (await svc.createItem(trip.id, { name: 'B' })) as any;
|
||||
expect(b.sort_order).toBe(a.sort_order + 1);
|
||||
});
|
||||
|
||||
it('TODO-SVC-007: createItem stores optional fields', () => {
|
||||
it('TODO-SVC-007: createItem stores optional fields', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = svc.createItem(trip.id, {
|
||||
const item = (await svc.createItem(trip.id, {
|
||||
name: 'Pack bag',
|
||||
category: 'Prep',
|
||||
description: 'All the gear',
|
||||
priority: 3,
|
||||
}) as any;
|
||||
})) as any;
|
||||
expect(item.category).toBe('Prep');
|
||||
expect(item.description).toBe('All the gear');
|
||||
expect(item.priority).toBe(3);
|
||||
});
|
||||
|
||||
it('TODO-SVC-008: listItems returns items ordered by sort_order', () => {
|
||||
it('TODO-SVC-008: listItems returns items ordered by sort_order', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
svc.createItem(trip.id, { name: 'First' });
|
||||
svc.createItem(trip.id, { name: 'Second' });
|
||||
svc.createItem(trip.id, { name: 'Third' });
|
||||
const items = svc.listItems(trip.id) as any[];
|
||||
await svc.createItem(trip.id, { name: 'First' });
|
||||
await svc.createItem(trip.id, { name: 'Second' });
|
||||
await svc.createItem(trip.id, { name: 'Third' });
|
||||
const items = (await svc.listItems(trip.id)) as any[];
|
||||
expect(items).toHaveLength(3);
|
||||
expect(items[0].sort_order).toBeLessThanOrEqual(items[1].sort_order);
|
||||
expect(items[1].sort_order).toBeLessThanOrEqual(items[2].sort_order);
|
||||
@@ -155,36 +156,36 @@ describe('listItems and createItem', () => {
|
||||
// ── updateItem ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('updateItem', () => {
|
||||
it('TODO-SVC-009: returns null for non-existent item', () => {
|
||||
it('TODO-SVC-009: returns null for non-existent item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(svc.updateItem(trip.id, 99999, { name: 'Ghost' }, ['name'])).toBeNull();
|
||||
expect(await svc.updateItem(trip.id, 99999, { name: 'Ghost' }, ['name'])).toBeNull();
|
||||
});
|
||||
|
||||
it('TODO-SVC-010: toggles checked status', () => {
|
||||
it('TODO-SVC-010: toggles checked status', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = svc.createItem(trip.id, { name: 'Visit museum' }) as any;
|
||||
const updated = svc.updateItem(trip.id, item.id, { checked: 1 }, ['checked']) as any;
|
||||
const item = (await svc.createItem(trip.id, { name: 'Visit museum' })) as any;
|
||||
const updated = (await svc.updateItem(trip.id, item.id, { checked: 1 }, ['checked'])) as any;
|
||||
expect(updated.checked).toBe(1);
|
||||
const back = svc.updateItem(trip.id, item.id, { checked: 0 }, ['checked']) as any;
|
||||
const back = (await svc.updateItem(trip.id, item.id, { checked: 0 }, ['checked'])) as any;
|
||||
expect(back.checked).toBe(0);
|
||||
});
|
||||
|
||||
it('TODO-SVC-011: updates name and category', () => {
|
||||
it('TODO-SVC-011: updates name and category', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = svc.createItem(trip.id, { name: 'Old' }) as any;
|
||||
const updated = svc.updateItem(trip.id, item.id, { name: 'New', category: 'Misc' }, ['name', 'category']) as any;
|
||||
const item = (await svc.createItem(trip.id, { name: 'Old' })) as any;
|
||||
const updated = (await svc.updateItem(trip.id, item.id, { name: 'New', category: 'Misc' }, ['name', 'category'])) as any;
|
||||
expect(updated.name).toBe('New');
|
||||
expect(updated.category).toBe('Misc');
|
||||
});
|
||||
|
||||
it('TODO-SVC-012: clears due_date when key is present with null value', () => {
|
||||
it('TODO-SVC-012: clears due_date when key is present with null value', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = svc.createItem(trip.id, { name: 'Task', due_date: '2026-06-01' }) as any;
|
||||
const updated = svc.updateItem(trip.id, item.id, { due_date: null }, ['due_date']) as any;
|
||||
const item = (await svc.createItem(trip.id, { name: 'Task', due_date: '2026-06-01' })) as any;
|
||||
const updated = (await svc.updateItem(trip.id, item.id, { due_date: null }, ['due_date'])) as any;
|
||||
expect(updated.due_date).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -192,32 +193,32 @@ describe('updateItem', () => {
|
||||
// ── deleteItem ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deleteItem', () => {
|
||||
it('TODO-SVC-013: returns false for non-existent item', () => {
|
||||
it('TODO-SVC-013: returns false for non-existent item', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(svc.deleteItem(trip.id, 99999)).toBe(false);
|
||||
expect(await svc.deleteItem(trip.id, 99999)).toBe(false);
|
||||
});
|
||||
|
||||
it('TODO-SVC-014: deletes item and returns true', () => {
|
||||
it('TODO-SVC-014: deletes item and returns true', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const item = svc.createItem(trip.id, { name: 'Gone' }) as any;
|
||||
expect(svc.deleteItem(trip.id, item.id)).toBe(true);
|
||||
expect(svc.listItems(trip.id)).toHaveLength(0);
|
||||
const item = (await svc.createItem(trip.id, { name: 'Gone' })) as any;
|
||||
expect(await svc.deleteItem(trip.id, item.id)).toBe(true);
|
||||
expect(await svc.listItems(trip.id)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── reorderItems ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('reorderItems', () => {
|
||||
it('TODO-SVC-015: assigns sort_order matching orderedIds array position', () => {
|
||||
it('TODO-SVC-015: assigns sort_order matching orderedIds array position', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const a = svc.createItem(trip.id, { name: 'A' }) as any;
|
||||
const b = svc.createItem(trip.id, { name: 'B' }) as any;
|
||||
const c = svc.createItem(trip.id, { name: 'C' }) as any;
|
||||
const a = (await svc.createItem(trip.id, { name: 'A' })) as any;
|
||||
const b = (await svc.createItem(trip.id, { name: 'B' })) as any;
|
||||
const c = (await svc.createItem(trip.id, { name: 'C' })) as any;
|
||||
|
||||
svc.reorderItems(trip.id, [c.id, a.id, b.id]);
|
||||
await svc.reorderItems(trip.id, [c.id, a.id, b.id]);
|
||||
|
||||
const rows = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
|
||||
expect(rows[0].id).toBe(c.id);
|
||||
@@ -229,47 +230,47 @@ describe('reorderItems', () => {
|
||||
// ── category assignees ────────────────────────────────────────────────────────
|
||||
|
||||
describe('getCategoryAssignees / updateCategoryAssignees', () => {
|
||||
it('TODO-SVC-016: returns empty object for new trip', () => {
|
||||
it('TODO-SVC-016: returns empty object for new trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
expect(svc.getCategoryAssignees(trip.id)).toEqual({});
|
||||
expect(await svc.getCategoryAssignees(trip.id)).toEqual({});
|
||||
});
|
||||
|
||||
it('TODO-SVC-017: updateCategoryAssignees sets assignees for a category', () => {
|
||||
it('TODO-SVC-017: updateCategoryAssignees sets assignees for a category', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const rows = svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, member.id]);
|
||||
const rows = await svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, member.id]);
|
||||
expect(rows).toHaveLength(2);
|
||||
|
||||
const assignees = svc.getCategoryAssignees(trip.id);
|
||||
const assignees = await svc.getCategoryAssignees(trip.id);
|
||||
expect(assignees['Packing']).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('TODO-SVC-018: updateCategoryAssignees with empty array clears assignees', () => {
|
||||
it('TODO-SVC-018: updateCategoryAssignees with empty array clears assignees', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
|
||||
svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id]);
|
||||
const cleared = svc.updateCategoryAssignees(trip.id, 'Packing', []);
|
||||
await svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id]);
|
||||
const cleared = await svc.updateCategoryAssignees(trip.id, 'Packing', []);
|
||||
expect(cleared).toHaveLength(0);
|
||||
|
||||
const assignees = svc.getCategoryAssignees(trip.id);
|
||||
const assignees = await svc.getCategoryAssignees(trip.id);
|
||||
expect(assignees['Packing']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TODO-SVC-019: getCategoryAssignees groups by category name', () => {
|
||||
it('TODO-SVC-019: getCategoryAssignees groups by category name', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
svc.updateCategoryAssignees(trip.id, 'Shopping', [owner.id]);
|
||||
svc.updateCategoryAssignees(trip.id, 'Logistics', [member.id]);
|
||||
await svc.updateCategoryAssignees(trip.id, 'Shopping', [owner.id]);
|
||||
await svc.updateCategoryAssignees(trip.id, 'Logistics', [member.id]);
|
||||
|
||||
const assignees = svc.getCategoryAssignees(trip.id);
|
||||
const assignees = await svc.getCategoryAssignees(trip.id);
|
||||
expect(Object.keys(assignees)).toHaveLength(2);
|
||||
expect(assignees['Shopping']).toHaveLength(1);
|
||||
expect(assignees['Logistics']).toHaveLength(1);
|
||||
@@ -279,42 +280,42 @@ describe('getCategoryAssignees / updateCategoryAssignees', () => {
|
||||
// you name belongs to it. The read-back joins users and hands the caller a
|
||||
// display name and an avatar, so an unfiltered id turns the endpoint into a
|
||||
// lookup for every account on the instance.
|
||||
it('TODO-SVC-017a: drops a user who is not on the trip, and says nothing about them', () => {
|
||||
it('TODO-SVC-017a: drops a user who is not on the trip, and says nothing about them', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const rows = svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, stranger.id, member.id]) as { user_id: number }[];
|
||||
const rows = (await svc.updateCategoryAssignees(trip.id, 'Packing', [owner.id, stranger.id, member.id])) as { user_id: number }[];
|
||||
|
||||
expect(rows.map(r => r.user_id).sort()).toEqual([owner.id, member.id].sort());
|
||||
expect(JSON.stringify(rows)).not.toContain(stranger.username);
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM todo_category_assignees WHERE user_id = ?').get(stranger.id)).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('TODO-SVC-017b: keeps a guest, who is a trip member like any other', () => {
|
||||
it('TODO-SVC-017b: keeps a guest, who is a trip member like any other', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: guest } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET is_guest = 1 WHERE id = ?').run(guest.id);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, guest.id);
|
||||
|
||||
const rows = svc.updateCategoryAssignees(trip.id, 'Packing', [guest.id]) as { user_id: number }[];
|
||||
const rows = (await svc.updateCategoryAssignees(trip.id, 'Packing', [guest.id])) as { user_id: number }[];
|
||||
expect(rows.map(r => r.user_id)).toEqual([guest.id]);
|
||||
});
|
||||
|
||||
it('TODO-SVC-020: updateCategoryAssignees replaces existing assignees (not append)', () => {
|
||||
it('TODO-SVC-020: updateCategoryAssignees replaces existing assignees (not append)', async () => {
|
||||
const { user: owner } = createUser(testDb);
|
||||
const { user: member } = createUser(testDb);
|
||||
const trip = createTrip(testDb, owner.id);
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
svc.updateCategoryAssignees(trip.id, 'Food', [owner.id, member.id]);
|
||||
await svc.updateCategoryAssignees(trip.id, 'Food', [owner.id, member.id]);
|
||||
// Replace with just owner
|
||||
svc.updateCategoryAssignees(trip.id, 'Food', [owner.id]);
|
||||
await svc.updateCategoryAssignees(trip.id, 'Food', [owner.id]);
|
||||
|
||||
const assignees = svc.getCategoryAssignees(trip.id);
|
||||
const assignees = await svc.getCategoryAssignees(trip.id);
|
||||
expect(assignees['Food']).toHaveLength(1);
|
||||
expect(assignees['Food'][0].user_id).toBe(owner.id);
|
||||
});
|
||||
@@ -334,7 +335,7 @@ describe('TodoService.canEdit', () => {
|
||||
it('TODO-SVC-090 asks for packing_edit and flags a non-owner as shared', async () => {
|
||||
const checkPermission = vi.fn(() => true);
|
||||
const permissions = { checkPermission } as unknown as PermissionsService;
|
||||
const withStub = new TodoService(new DatabaseService(testDb), permissions, new RealtimeService());
|
||||
const withStub = new TodoService(new DatabaseService(testDb), permissions, new RealtimeService(), await createTestUnitOfWork(testDb));
|
||||
const trip = { id: 1, user_id: 1 } as never;
|
||||
|
||||
expect(await withStub.canEdit(trip, { id: 1, role: 'user' } as never)).toBe(true);
|
||||
|
||||
@@ -48,7 +48,8 @@ import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
||||
const dbs = new DatabaseService(testDb);
|
||||
let svc: TripInviteService;
|
||||
beforeAll(async () => {
|
||||
svc = new TripInviteService(dbs, new PermissionsService(dbs, await createTestUnitOfWork(dbs.connection)), new TripMembershipService(dbs));
|
||||
const uow = await createTestUnitOfWork(dbs.connection);
|
||||
svc = new TripInviteService(dbs, new PermissionsService(dbs, uow), new TripMembershipService(dbs), uow);
|
||||
});
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
@@ -62,69 +63,69 @@ function setup() {
|
||||
}
|
||||
|
||||
describe('TripInviteService', () => {
|
||||
it('TRIP-INVITE-001: no link exists initially', () => {
|
||||
it('TRIP-INVITE-001: no link exists initially', async () => {
|
||||
const { trip } = setup();
|
||||
expect(svc.get(trip.id)).toBeNull();
|
||||
expect(await svc.get(trip.id)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-INVITE-002: create returns a token and get reads it back', () => {
|
||||
it('TRIP-INVITE-002: create returns a token and get reads it back', async () => {
|
||||
const { owner, trip } = setup();
|
||||
const info = svc.createOrRotate(trip.id, owner.id);
|
||||
const info = await svc.createOrRotate(trip.id, owner.id);
|
||||
expect(info.token).toMatch(/^[A-Za-z0-9_-]{20,}$/);
|
||||
expect(svc.get(trip.id)?.token).toBe(info.token);
|
||||
expect((await svc.get(trip.id))?.token).toBe(info.token);
|
||||
});
|
||||
|
||||
it('TRIP-INVITE-003: rotating replaces the token and keeps a single row', () => {
|
||||
it('TRIP-INVITE-003: rotating replaces the token and keeps a single row', async () => {
|
||||
const { owner, trip } = setup();
|
||||
const first = svc.createOrRotate(trip.id, owner.id);
|
||||
const second = svc.createOrRotate(trip.id, owner.id);
|
||||
const first = await svc.createOrRotate(trip.id, owner.id);
|
||||
const second = await svc.createOrRotate(trip.id, owner.id);
|
||||
expect(second.token).not.toBe(first.token);
|
||||
const count = testDb.prepare('SELECT COUNT(*) as n FROM trip_invite_tokens WHERE trip_id = ?').get(trip.id) as { n: number };
|
||||
expect(count.n).toBe(1);
|
||||
// The old token no longer resolves.
|
||||
expect(svc.resolve(first.token)).toBeNull();
|
||||
expect(await svc.resolve(first.token)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-INVITE-004: resolve returns the trip for a valid token', () => {
|
||||
it('TRIP-INVITE-004: resolve returns the trip for a valid token', async () => {
|
||||
const { owner, trip } = setup();
|
||||
const info = svc.createOrRotate(trip.id, owner.id);
|
||||
expect(svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title });
|
||||
const info = await svc.createOrRotate(trip.id, owner.id);
|
||||
expect(await svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title });
|
||||
});
|
||||
|
||||
it('TRIP-INVITE-005: an expired token does not resolve (ISO expiry, incl. same-day)', () => {
|
||||
it('TRIP-INVITE-005: an expired token does not resolve (ISO expiry, incl. same-day)', async () => {
|
||||
const { owner, trip } = setup();
|
||||
const info = svc.createOrRotate(trip.id, owner.id);
|
||||
const info = await svc.createOrRotate(trip.id, owner.id);
|
||||
// Use the exact ISO-8601 format the service writes, one hour in the past —
|
||||
// this catches the lexicographic-SQL-comparison bug where a same-UTC-day
|
||||
// expiry would otherwise still resolve.
|
||||
testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?')
|
||||
.run(new Date(Date.now() - 3600_000).toISOString(), trip.id);
|
||||
expect(svc.resolve(info.token)).toBeNull();
|
||||
expect(await svc.resolve(info.token)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-INVITE-005b: a not-yet-expired token still resolves', () => {
|
||||
it('TRIP-INVITE-005b: a not-yet-expired token still resolves', async () => {
|
||||
const { owner, trip } = setup();
|
||||
const info = svc.createOrRotate(trip.id, owner.id);
|
||||
const info = await svc.createOrRotate(trip.id, owner.id);
|
||||
testDb.prepare('UPDATE trip_invite_tokens SET expires_at = ? WHERE trip_id = ?')
|
||||
.run(new Date(Date.now() + 3600_000).toISOString(), trip.id);
|
||||
expect(svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title });
|
||||
expect(await svc.resolve(info.token)).toEqual({ trip_id: trip.id, title: trip.title });
|
||||
});
|
||||
|
||||
it('TRIP-INVITE-006: delete removes the link', () => {
|
||||
it('TRIP-INVITE-006: delete removes the link', async () => {
|
||||
const { owner, trip } = setup();
|
||||
const info = svc.createOrRotate(trip.id, owner.id);
|
||||
svc.remove(trip.id);
|
||||
expect(svc.get(trip.id)).toBeNull();
|
||||
expect(svc.resolve(info.token)).toBeNull();
|
||||
const info = await svc.createOrRotate(trip.id, owner.id);
|
||||
await svc.remove(trip.id);
|
||||
expect(await svc.get(trip.id)).toBeNull();
|
||||
expect(await svc.resolve(info.token)).toBeNull();
|
||||
});
|
||||
|
||||
it('TRIP-INVITE-007: an expiry in days is written as an ISO timestamp; non-positive means none', () => {
|
||||
it('TRIP-INVITE-007: an expiry in days is written as an ISO timestamp; non-positive means none', async () => {
|
||||
const { owner, trip } = setup();
|
||||
const bounded = svc.createOrRotate(trip.id, owner.id, 7);
|
||||
const bounded = await svc.createOrRotate(trip.id, owner.id, 7);
|
||||
const expires = new Date(bounded.expires_at!).getTime();
|
||||
expect(expires).toBeGreaterThan(Date.now() + 6 * 86400000);
|
||||
expect(expires).toBeLessThan(Date.now() + 8 * 86400000);
|
||||
expect(svc.createOrRotate(trip.id, owner.id, 0).expires_at).toBeNull();
|
||||
expect(svc.createOrRotate(trip.id, owner.id, -3).expires_at).toBeNull();
|
||||
expect((await svc.createOrRotate(trip.id, owner.id, 0)).expires_at).toBeNull();
|
||||
expect((await svc.createOrRotate(trip.id, owner.id, -3)).expires_at).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,7 +120,7 @@ const buildReadModel = async (database: DatabaseService, roster: TripMembersServ
|
||||
new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), await accommodationsOver(dbs()), await createTestUnitOfWork(dbs().connection)),
|
||||
new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), makeStorageFixture('').storage, new RateLimitService()),
|
||||
placesSvc,
|
||||
new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService()),
|
||||
new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), await createTestUnitOfWork(dbs().connection)),
|
||||
new FilesService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new EphemeralTokenService(), makeStorageFixture('').storage),
|
||||
);
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ beforeAll(async () => {
|
||||
new ReservationsService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs()), accommodationsSvc, await createTestUnitOfWork(dbs().connection)),
|
||||
new CollabService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), notificationsStub(), coversFx.storage, new RateLimitService()),
|
||||
placesSvc,
|
||||
new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService()),
|
||||
new TodoService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), await createTestUnitOfWork(dbs().connection)),
|
||||
new FilesService(dbs(), new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)), new RealtimeService(), new EphemeralTokenService(), coversFx.storage),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user