|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25484] `POST /packages/:id/publish-drafts` probes a published dashboard's |
| 5 | + * widgets AS THE PUBLISHING CALLER. |
| 6 | + * |
| 7 | + * The probe docblock promises "the SAME `queryDataset` path the dashboard |
| 8 | + * renderer hits", and the renderer passes the caller's context. The probe |
| 9 | + * passed none, so the analytics service's object-level read admission refused |
| 10 | + * every dataset over a permission-gated object and a correct widget came back |
| 11 | + * as an error-tier `widget_query_failed` ("Access denied: reading … is not |
| 12 | + * permitted for this user") telling the author to fix a dataset that was fine. |
| 13 | + * |
| 14 | + * What is real here: the door (`HttpDispatcher.handlePackages`), the protocol's |
| 15 | + * `publishPackageDrafts`, `runBuildProbes`, and an `AnalyticsService` whose |
| 16 | + * `admitObjectRead` judges the context it is handed — the gate that refused. |
| 17 | + * What is a double: the draft store and the phase-1/phase-2 publish seams |
| 18 | + * (the same seams `packages/objectql/src/build-probes.test.ts` stubs), and the |
| 19 | + * driver the analytics strategy reads. |
| 20 | + * |
| 21 | + * The two legs are each other's control: the reader's publish must come back |
| 22 | + * clean, and the non-reader's must still carry the refusal, so neither "the |
| 23 | + * probe stopped running" nor "the probe now reads as the system" can pass. |
| 24 | + */ |
| 25 | +import { afterEach, describe, expect, it, vi } from 'vitest'; |
| 26 | +import { DatasetSchema } from '@objectstack/spec/ui'; |
| 27 | +import type { ExecutionContext } from '@objectstack/spec/kernel'; |
| 28 | +import { AnalyticsService } from '@objectstack/service-analytics'; |
| 29 | +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; |
| 30 | +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; |
| 31 | +import { HttpDispatcher } from '../http-dispatcher.js'; |
| 32 | + |
| 33 | +/** The object read admission walls off from everyone but the reader. */ |
| 34 | +const GATED = 'task_item'; |
| 35 | + |
| 36 | +const DATASET = DatasetSchema.parse({ |
| 37 | + name: 'task_metrics', |
| 38 | + label: 'Task metrics', |
| 39 | + object: GATED, |
| 40 | + dimensions: [], |
| 41 | + measures: [{ name: 'task_count', label: 'Tasks', aggregate: 'count' }], |
| 42 | +}); |
| 43 | + |
| 44 | +const ITEMS: Record<string, unknown> = { |
| 45 | + 'dashboard task_board': { |
| 46 | + name: 'task_board', |
| 47 | + widgets: [{ id: 'w_tasks', dataset: 'task_metrics', values: ['task_count'] }], |
| 48 | + }, |
| 49 | + 'dataset task_metrics': DATASET, |
| 50 | +}; |
| 51 | + |
| 52 | +/** Both callers may publish; only the reader may read `task_item`. */ |
| 53 | +const caller = (userId: string) => ({ |
| 54 | + request: {}, |
| 55 | + executionContext: { |
| 56 | + userId, |
| 57 | + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], |
| 58 | + }, |
| 59 | +}) as any; |
| 60 | +const READER = 'u_reader'; |
| 61 | +const NON_READER = 'u_publisher_without_read'; |
| 62 | + |
| 63 | +function makeDoor() { |
| 64 | + const admissionContexts: Array<ExecutionContext | undefined> = []; |
| 65 | + const analytics = new AnalyticsService({ |
| 66 | + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), |
| 67 | + admitObjectRead: (object, context) => { |
| 68 | + admissionContexts.push(context); |
| 69 | + return object !== GATED || context?.userId === READER; |
| 70 | + }, |
| 71 | + executeAggregate: async () => [{ task_count: 7 }], |
| 72 | + }); |
| 73 | + |
| 74 | + const protocol = new ObjectStackProtocolImplementation({} as never); |
| 75 | + const p = protocol as any; |
| 76 | + p.ensureOverlayIndex = async () => {}; |
| 77 | + p.getOverlayRepo = () => ({ |
| 78 | + listDrafts: async () => [ |
| 79 | + { type: 'dataset', name: 'task_metrics' }, |
| 80 | + { type: 'dashboard', name: 'task_board' }, |
| 81 | + ], |
| 82 | + get: async (ref: any, opts: any) => |
| 83 | + opts?.state === 'draft' ? { body: ITEMS[`${ref.type} ${ref.name}`], hash: 'h' } : null, |
| 84 | + }); |
| 85 | + vi.spyOn(p, 'promoteDraftForPublish').mockImplementation(async (req: any) => ({ |
| 86 | + singularType: req.type, |
| 87 | + orgId: null, |
| 88 | + advisories: [], |
| 89 | + result: { version: 'h', seq: 1, item: { body: ITEMS[`${req.type} ${req.name}`] }, packageId: null }, |
| 90 | + })); |
| 91 | + vi.spyOn(p, 'runPublishSideEffects').mockResolvedValue({}); |
| 92 | + p.getMetaItem = async ({ type, name }: any) => ({ item: ITEMS[`${type} ${name}`] }); |
| 93 | + p.getMetaItems = async () => ({ items: [] }); |
| 94 | + const engineFinds: Array<{ object: string; context: unknown }> = []; |
| 95 | + p.engine = { |
| 96 | + find: async (object: string, query: any) => { |
| 97 | + engineFinds.push({ object, context: query?.context }); |
| 98 | + const rows = [{ id: 'r1' }, { id: 'r2' }]; |
| 99 | + return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; |
| 100 | + }, |
| 101 | + // The ADR-0067 pre-publish capture: never published, so no active row. |
| 102 | + findOne: async (object: string, query: unknown) => { |
| 103 | + assertEngineFindOnePredicate(object, query as never); |
| 104 | + return null; |
| 105 | + }, |
| 106 | + // The ADR-0067 commit row the batch writes; not under test here. |
| 107 | + insert: async (_object: string, row: unknown) => row, |
| 108 | + }; |
| 109 | + p.getServicesRegistry = () => new Map<string, unknown>([['analytics', analytics]]); |
| 110 | + |
| 111 | + const kernel: any = { |
| 112 | + getService: (name: string) => { |
| 113 | + if (name === 'protocol') return Promise.resolve(protocol); |
| 114 | + if (name === 'objectql') { |
| 115 | + return Promise.resolve({ |
| 116 | + insert: vi.fn(), find: vi.fn(), update: vi.fn(), |
| 117 | + registry: { getAllPackages: vi.fn().mockReturnValue([]) }, |
| 118 | + }); |
| 119 | + } |
| 120 | + if (name === 'metadata') return Promise.resolve({ getObject: vi.fn() }); |
| 121 | + return null; |
| 122 | + }, |
| 123 | + context: { getService: () => null, trigger: vi.fn() }, |
| 124 | + }; |
| 125 | + return { dispatcher: new HttpDispatcher(kernel), admissionContexts, engineFinds }; |
| 126 | +} |
| 127 | + |
| 128 | +async function publishAs(userId: string) { |
| 129 | + const door = makeDoor(); |
| 130 | + const result = await door.dispatcher.handlePackages( |
| 131 | + '/com.tasks/publish-drafts', 'POST', {}, {}, caller(userId), |
| 132 | + ); |
| 133 | + expect(result.response?.status).toBe(200); |
| 134 | + const data: any = (result.response as any)?.body?.data; |
| 135 | + expect(data?.outcome).toBe('published'); |
| 136 | + return { ...door, probes: data?.probes }; |
| 137 | +} |
| 138 | + |
| 139 | +afterEach(() => vi.restoreAllMocks()); |
| 140 | + |
| 141 | +describe('publish-drafts widget probes run as the publishing caller (#25484)', () => { |
| 142 | + it('a correct dataset over a gated object the caller CAN read yields no error-tier issue', async () => { |
| 143 | + const { probes, admissionContexts, engineFinds } = await publishAs(READER); |
| 144 | + |
| 145 | + expect(probes?.checked?.widgets).toBe(1); |
| 146 | + expect(probes.issues.filter((i: any) => i.severity === 'error')).toEqual([]); |
| 147 | + // Admission was asked about the caller, never about a system principal. |
| 148 | + expect(admissionContexts.length).toBeGreaterThan(0); |
| 149 | + for (const ctx of admissionContexts) { |
| 150 | + expect(ctx?.userId).toBe(READER); |
| 151 | + expect((ctx as any)?.isSystem).toBeUndefined(); |
| 152 | + } |
| 153 | + // No system read was used to judge this widget either. |
| 154 | + expect(engineFinds.filter((f) => (f.context as any)?.isSystem)).toEqual([]); |
| 155 | + }); |
| 156 | + |
| 157 | + it('a caller who CANNOT read the dataset still gets the error-tier widget_query_failed', async () => { |
| 158 | + const { probes, admissionContexts } = await publishAs(NON_READER); |
| 159 | + |
| 160 | + expect(probes?.checked?.widgets).toBe(1); |
| 161 | + const failed = probes.issues.filter((i: any) => i.code === 'widget_query_failed'); // adr0112-ok: D6c build-probe diagnostics code |
| 162 | + expect(failed).toHaveLength(1); |
| 163 | + expect(failed[0]).toMatchObject({ |
| 164 | + layer: 'runtime', |
| 165 | + severity: 'error', |
| 166 | + artifact: { type: 'dashboard', name: 'task_board' }, |
| 167 | + ref: { type: 'dataset', name: 'task_metrics', member: 'w_tasks' }, |
| 168 | + }); |
| 169 | + expect(admissionContexts.every((ctx) => ctx?.userId === NON_READER)).toBe(true); |
| 170 | + }); |
| 171 | +}); |
0 commit comments