fix(vk): Phase 2.3.1 derive effectiveCapabilities from actual resolved auth mode

This commit is contained in:
Ochenstarik 2026-08-18 14:50:47 +07:00
parent 1495067501
commit 4e4370d6f1
8 changed files with 397 additions and 6 deletions

View file

@ -0,0 +1,16 @@
# Task Done: Phase 2.3.1 — Effective Capabilities Truthfulness Gate
**Status:** DONE
**Assigned to:** Antigravity (@orchestrator)
**Date:** 2026-08-18
**Base Commit:** `1495067`
## Findings & Fixes
- **Claude C-4 finding**: `POST /api/posts/preview` derived `effectiveCapabilities` from session existence rather than the actual `VkAuthContext` used by `fetchPost`.
- **Fix**:
- `PostMetadata` now exposes safe, non-secret `resolvedAuthType?: 'SERVICE' | 'USER' | 'COMMUNITY'`.
- `VkProvider.executeFetchPost` records `resolvedAuthType: authContext.type` (tokens/secrets are never exposed).
- `POST /api/posts/preview` calculates `effectiveCapabilities` truthfully from `post.resolvedAuthType`.
- `GET /api/giveaways/[id]` checks `defaultUserRepository.getUserCredentials` truthfully instead of hardcoding synthetic `{ type: 'USER' }`.
- **Tests Added**: `tests/effective-capabilities-truthfulness.test.ts` (6 tests covering all 5 prompt requirements + giveaway detail check).
- **Test Suite Status**: 268/268 tests passing (47 test files).

View file

@ -4,6 +4,7 @@ import { generalApiRateLimiter } from '@/lib/rate-limiter';
import { resolveClientIp } from '@/lib/client-ip';
import { requireGiveawayOwner } from '@/lib/auth/auth-guard';
import { resolveEffectiveCapabilities } from '@/providers/vk/vk-capabilities';
import { defaultUserRepository } from '@/lib/repository/user-repository';
export const dynamic = 'force-dynamic';
@ -17,10 +18,18 @@ export async function GET(
generalApiRateLimiter.assertAllowed(`giveaway-get:${clientIp}`);
// Enforce giveaway ownership authorization
const { giveaway } = await requireGiveawayOwner(req, id);
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
// Resolve runtime effective capabilities for the authenticated organizer
const effectiveCapabilities = resolveEffectiveCapabilities({ type: 'USER', token: 'active' });
// Resolve runtime effective capabilities truthfully based on stored organizer credentials
let authType: 'SERVICE' | 'USER' = 'SERVICE';
if (sessionUser?.id) {
const cred = await defaultUserRepository.getUserCredentials(sessionUser.id);
if (cred?.encryptedAccessToken) {
authType = 'USER';
}
}
const effectiveCapabilities = resolveEffectiveCapabilities({ type: authType });
return NextResponse.json({
success: true,

View file

@ -21,9 +21,9 @@ export async function POST(req: NextRequest) {
// Fetch post with optional organizer session context for private/restricted access probe
const post = await provider.fetchPost(validated.url, { organizerId: sessionUser?.id });
// Derive effective capabilities based on authentication context
// Derive effective capabilities based on the actual auth mode used to access the post
const effectiveCapabilities = resolveEffectiveCapabilities(
sessionUser ? { type: 'USER', token: 'active' } : { type: 'SERVICE', token: 'active' }
post.resolvedAuthType ? { type: post.resolvedAuthType } : undefined
);
return NextResponse.json({

View file

@ -48,4 +48,5 @@ export interface PostMetadata {
commentsCount: number;
repostsCount: number;
publishedAt?: Date;
resolvedAuthType?: 'SERVICE' | 'USER' | 'COMMUNITY';
}

View file

@ -20,7 +20,7 @@ export const STATIC_VK_CAPABILITIES: ProviderCapabilities = {
/**
* Derives effective capabilities at runtime based on the resolved auth context and target resource.
*/
export function resolveEffectiveCapabilities(authContext?: VkAuthContext): EffectiveCapabilities {
export function resolveEffectiveCapabilities(authContext?: { type: 'SERVICE' | 'USER' | 'COMMUNITY' } | VkAuthContext | null): EffectiveCapabilities {
const accessMode: VkAccessMode = !authContext || authContext.type === 'SERVICE'
? 'PUBLIC_SERVICE'
: authContext.type === 'USER'

View file

@ -131,6 +131,7 @@ export class VkMockProvider implements SocialMediaProvider {
commentsCount: 86,
repostsCount: 37,
publishedAt: new Date(Date.now() - 3600000 * 24 * 2),
resolvedAuthType: 'SERVICE',
};
}

View file

@ -177,6 +177,7 @@ export class VkProvider implements SocialMediaProvider {
commentsCount: post.comments?.count || 0,
repostsCount: post.reposts?.count || 0,
publishedAt: post.date ? new Date(post.date * 1000) : undefined,
resolvedAuthType: authContext.type,
};
}

View file

@ -0,0 +1,363 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { POST as previewPost } from '../src/app/api/posts/preview/route';
import { GET as giveawayDetailGet } from '../src/app/api/giveaways/[id]/route';
import { MemoryUserRepository, setUserRepository } from '../src/lib/repository/user-repository';
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
import { AesGcmTokenVault } from '../src/lib/auth/token-vault';
import { TokenRefresher } from '../src/lib/auth/token-refresher';
import { VkAuthContextResolver } from '../src/integrations/vk/vk-auth-resolver';
import { VkProvider } from '../src/providers/vk/vk-provider';
import { IVkClient } from '../src/integrations/vk/vk-client';
import { VkAuthContext } from '../src/integrations/vk/vk-types';
import { VkPrivateResourceError } from '../src/integrations/vk/vk-errors';
import { ProviderFactory } from '../src/providers/factory';
import { GiveawayStore } from '../src/lib/giveaway-store';
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
describe('Phase 2.3.1 — Effective Capabilities Truthfulness Gate', () => {
let userRepo: MemoryUserRepository;
let tokenVault: AesGcmTokenVault;
let tokenRefresher: TokenRefresher;
let authResolver: VkAuthContextResolver;
let loggedInUser: any;
let sessionCookie: string;
let loggedInUserWithoutCreds: any;
let sessionCookieNoCreds: string;
const userTokenPlain = 'vk1.a.user_organizer_valid_token_123';
const serviceTokenPlain = 'vk_service_token_456';
beforeEach(async () => {
userRepo = new MemoryUserRepository();
setUserRepository(userRepo);
defaultSessionStore.clear();
tokenVault = new AesGcmTokenVault('test-master-token-encryption-key-32b!');
tokenRefresher = new TokenRefresher(userRepo, tokenVault);
authResolver = new VkAuthContextResolver(tokenRefresher);
process.env.VK_SERVICE_TOKEN = serviceTokenPlain;
process.env.USE_VK_MOCK = 'false';
// 1. User with valid encrypted VK credential
const encryptedAccessToken = await tokenVault.encrypt(userTokenPlain);
loggedInUser = await userRepo.upsertUserWithTokens({
vkUserId: '11112222',
firstName: 'Alice',
lastName: 'Organizer',
encryptedAccessToken,
expiresIn: 3600,
});
const sessionId1 = await defaultSessionStore.createSession(loggedInUser);
sessionCookie = `${SESSION_COOKIE_NAME}=${sessionId1}`;
// 2. User with session but NO VK credentials in repository
loggedInUserWithoutCreds = await userRepo.upsertUserWithTokens({
vkUserId: '33334444',
firstName: 'Charlie',
lastName: 'NoCreds',
encryptedAccessToken: '', // empty / missing
});
// Remove credentials for Charlie
(userRepo as any).credentials.delete(loggedInUserWithoutCreds.id);
const sessionId2 = await defaultSessionStore.createSession(loggedInUserWithoutCreds);
sessionCookieNoCreds = `${SESSION_COOKIE_NAME}=${sessionId2}`;
// Reset GiveawayStore
GiveawayStore.setRepository(new MemoryGiveawayRepository());
});
// ─── Test 1: Logged-in user + public post + SERVICE succeeds → PUBLIC_SERVICE ───
it('1. logged-in user + public post + SERVICE succeeds reports accessMode PUBLIC_SERVICE', async () => {
const mockClient: IVkClient = {
call: async (_method, _params, auth) => {
expect(auth.type).toBe('SERVICE');
return {
items: [
{
id: 101,
owner_id: -101,
date: 1700000000,
text: 'Public wall post',
likes: { count: 10 },
comments: { count: 2 },
reposts: { count: 0 },
},
],
} as any;
},
};
const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver);
ProviderFactory.getVkProvider = () => provider;
const req = new NextRequest('http://localhost:3000/api/posts/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': sessionCookie,
},
body: JSON.stringify({ url: 'https://vk.com/wall-101_101' }),
});
const res = await previewPost(req);
const json = await res.json();
expect(res.status).toBe(200);
expect(json.success).toBe(true);
// Truthfulness: Despite user having a session, the post was fetched with SERVICE token
expect(json.post.resolvedAuthType).toBe('SERVICE');
expect(json.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE');
expect(json.effectiveCapabilities.adminDetection).toBe(false);
});
// ─── Test 2: SERVICE denied + USER succeeds → ORGANIZER_USER ─────────────────
it('2. SERVICE denied (private) + USER fallback succeeds reports accessMode ORGANIZER_USER', async () => {
const authSequence: VkAuthContext[] = [];
const mockClient: IVkClient = {
call: async (_method, _params, auth) => {
authSequence.push(auth!);
if (auth.type === 'SERVICE') {
// Private post access denied on service token
throw new VkPrivateResourceError('Post is private', { errorCode: 15 });
}
// USER token succeeds
return {
items: [
{
id: 202,
owner_id: -202,
date: 1700000000,
text: 'Private group post visible to organizer',
likes: { count: 25 },
comments: { count: 7 },
reposts: { count: 0 },
},
],
} as any;
},
};
const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver);
ProviderFactory.getVkProvider = () => provider;
const req = new NextRequest('http://localhost:3000/api/posts/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': sessionCookie,
},
body: JSON.stringify({ url: 'https://vk.com/wall-202_202' }),
});
const res = await previewPost(req);
const json = await res.json();
expect(res.status).toBe(200);
expect(json.success).toBe(true);
expect(authSequence).toHaveLength(2);
expect(authSequence[0].type).toBe('SERVICE');
expect(authSequence[1].type).toBe('USER');
// Truthfulness: Since USER fallback was actually used, it accurately reports ORGANIZER_USER
expect(json.post.resolvedAuthType).toBe('USER');
expect(json.effectiveCapabilities.accessMode).toBe('ORGANIZER_USER');
});
// ─── Test 3: Logged-in user with missing credentials + public SERVICE succeeds ─
it('3. logged-in user with missing USER credential + public SERVICE access does NOT claim ORGANIZER_USER', async () => {
const mockClient: IVkClient = {
call: async () => ({
items: [
{
id: 303,
owner_id: -303,
date: 1700000000,
text: 'Public post for user without VK creds',
likes: { count: 1 },
comments: { count: 0 },
reposts: { count: 0 },
},
],
} as any),
};
const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver);
ProviderFactory.getVkProvider = () => provider;
const req = new NextRequest('http://localhost:3000/api/posts/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': sessionCookieNoCreds, // Charlie has no VK credentials
},
body: JSON.stringify({ url: 'https://vk.com/wall-303_303' }),
});
const res = await previewPost(req);
const json = await res.json();
expect(res.status).toBe(200);
expect(json.success).toBe(true);
// Truthfulness: must NOT claim ORGANIZER_USER
expect(json.post.resolvedAuthType).toBe('SERVICE');
expect(json.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE');
});
// ─── Test 4: Anonymous + SERVICE succeeds → PUBLIC_SERVICE ───────────────────
it('4. anonymous request + SERVICE succeeds reports accessMode PUBLIC_SERVICE', async () => {
const mockClient: IVkClient = {
call: async () => ({
items: [
{
id: 404,
owner_id: -404,
date: 1700000000,
text: 'Anonymous preview post',
likes: { count: 50 },
comments: { count: 12 },
reposts: { count: 3 },
},
],
} as any),
};
const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver);
ProviderFactory.getVkProvider = () => provider;
const req = new NextRequest('http://localhost:3000/api/posts/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// No Cookie header
},
body: JSON.stringify({ url: 'https://vk.com/wall-404_404' }),
});
const res = await previewPost(req);
const json = await res.json();
expect(res.status).toBe(200);
expect(json.success).toBe(true);
expect(json.post.resolvedAuthType).toBe('SERVICE');
expect(json.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE');
});
// ─── Test 5: No token/credential/auth object leaked in preview response ───────
it('5. preview response never contains tokens, credentials, or internal auth objects', async () => {
const mockClient: IVkClient = {
call: async () => ({
items: [
{
id: 505,
owner_id: -505,
date: 1700000000,
text: 'Security check post',
likes: { count: 2 },
comments: { count: 1 },
reposts: { count: 0 },
},
],
} as any),
};
const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver);
ProviderFactory.getVkProvider = () => provider;
const req = new NextRequest('http://localhost:3000/api/posts/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': sessionCookie,
},
body: JSON.stringify({ url: 'https://vk.com/wall-505_505' }),
});
const res = await previewPost(req);
const text = await res.text();
// Security check: raw response text must NOT leak any secret strings
expect(text).not.toContain(userTokenPlain);
expect(text).not.toContain(serviceTokenPlain);
expect(text).not.toContain('tokenVault');
expect(text).not.toContain('encryptedAccessToken');
expect(text).not.toContain('refreshToken');
const parsed = JSON.parse(text);
// Post only contains safe literal resolvedAuthType string
expect(parsed.post.resolvedAuthType).toBe('SERVICE');
expect(parsed.post).not.toHaveProperty('token');
expect(parsed.post).not.toHaveProperty('authContext');
});
// ─── Test 6: Giveaway detail effectiveCapabilities truthfulness ──────────────
it('6. giveaway detail endpoint reports ORGANIZER_USER when user has credentials, PUBLIC_SERVICE when missing', async () => {
// 6a. Create giveaway for Alice (has credentials)
const gwAlice = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-101_101',
platform: 'VK',
platformOwnerId: '-101',
platformPostId: '101',
title: 'Alice Giveaway',
organizerId: loggedInUser.id,
post: {
platform: 'VK',
ownerId: '-101',
postId: '101',
sourceUrl: 'https://vk.com/wall-101_101',
title: 'Alice Giveaway',
text: 'Text',
likesCount: 10,
commentsCount: 2,
repostsCount: 0,
},
});
const reqAlice = new NextRequest(`http://localhost:3000/api/giveaways/${gwAlice.id}`, {
method: 'GET',
headers: { 'Cookie': sessionCookie },
});
const resAlice = await giveawayDetailGet(reqAlice, { params: { id: gwAlice.id } });
const jsonAlice = await resAlice.json();
expect(resAlice.status).toBe(200);
expect(jsonAlice.effectiveCapabilities.accessMode).toBe('ORGANIZER_USER');
// 6b. Create giveaway for Charlie (no credentials)
const gwCharlie = await GiveawayStore.create({
sourceUrl: 'https://vk.com/wall-303_303',
platform: 'VK',
platformOwnerId: '-303',
platformPostId: '303',
title: 'Charlie Giveaway',
organizerId: loggedInUserWithoutCreds.id,
post: {
platform: 'VK',
ownerId: '-303',
postId: '303',
sourceUrl: 'https://vk.com/wall-303_303',
title: 'Charlie Giveaway',
text: 'Text',
likesCount: 1,
commentsCount: 0,
repostsCount: 0,
},
});
const reqCharlie = new NextRequest(`http://localhost:3000/api/giveaways/${gwCharlie.id}`, {
method: 'GET',
headers: { 'Cookie': sessionCookieNoCreds },
});
const resCharlie = await giveawayDetailGet(reqCharlie, { params: { id: gwCharlie.id } });
const jsonCharlie = await resCharlie.json();
expect(resCharlie.status).toBe(200);
expect(jsonCharlie.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE');
});
});