Compare commits

...

5 Commits

Author SHA1 Message Date
Tushar Vats
16849967c5 feat(frontend): register search() as a query builder function (#12513)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

Stacked on SigNoz/signoz#12491 — review that one first.

#12491 makes the frontend parser lex and parse `search()`. This makes
the editor act on it. Scoped to `search('x')` and `search(x)` for now —
scope arguments are not handled yet.

- **Function suggestion.** The cursor on `search` resolved to no
context, so the suggestion list never opened and `search()` was never
offered as a completion. Registered alongside the `has` family, in the
same two places `hasToken` needed.
- **The term is free text, not a key.** `search(x)` lexes its term as a
key, so the editor offered attribute keys inside the call and would
complete one into the term — and pair extraction turned it into a filter
item keyed `x`, which the log detail drawer rebuilds into a real filter.
Key and value suggestions are now suppressed inside a `search()` call,
and its argument no longer produces a pair. `has(key, value)` does take
a real key, so its suggestions are untouched.
- **Logs only.** `FilterOperatorSearch` is implemented in
`logstelemetryschema` alone; traces and metrics reject it as an
unsupported operator, so the suggestion is gated to the logs signal.
- **Recents.** `SEARCH(...)` and `search(...)` no longer dedup as two
distinct recent queries.

#### Additional Information

`search` is a reserved word now, so `search = 'x'` and `search exists`
no longer parse — the same tradeoff `has`/`hasAny` already carry,
matching the backend grammar.

Three things deliberately left out:

- After picking any function from the autocomplete the cursor lands
outside the brackets, so you have to arrow back before typing the
argument. Long-standing behaviour across the whole `has` family, but
`search()` is the case where an empty call is always a syntax error.
Filed as SigNoz/engineering-pod#5893.
- `has(key, value)` still contributes a phantom filter item keyed on its
first argument, which reaches the trace waterfall's API query, the
metrics drilldown and the infra filter telemetry. Fixing it needs the
autocomplete to keep reading that argument as a key, so it is not a
one-liner.
- Scope arguments (`search('err', body)`) parse but are not supported
here.

`isCursorInSearchTerm` runs on every cursor move, so it text-matches
`search` before paying for a lex. A `SEARCH` token only exists where the
lexer matched exactly those six letters — a word character on either
side would have produced a `KEY` — so the pre-check cannot produce a
false negative. `body = 'search this'` is covered by a test, since only
the lexer can tell that one is a quoted value.

Unrelated to this PR: `QuerySearch.test.tsx › fetches key suggestions on
mount for LOGS` is flaky on `main` too. An earlier test in that file
types `http.` and never unmounts, so its debounced `getKeySuggestions`
resolves after this test's `mockClear()` and wins the `mock.calls[length
- 1]` read.
2026-08-12 12:49:25 +00:00
Vikrant Gupta
c8e7685f06 test(integration): drop the deprecated user endpoints from the suite (#12529)
#### Description

- The integration suite was the last consumer of the five deprecated v1
user endpoints. It now provisions through `POST /api/v2/users`, `PUT
/api/v2/users/{id}/reset_password_tokens` and `POST
/api/v2/factor_password/reset`, so those routes can be deleted once the
remaining upstream consumer is deployed.
- Role assignment moves off the deprecated `POST
/api/v2/users/{id}/roles` and `DELETE /api/v2/users/{id}/roles/{roleId}`
onto `/api/v2/user_roles`. Removal is keyed by the `user_role` entry id,
so the tests read it from `GET /api/v2/users/{id}`. `GET
/api/v2/users/{id}/roles` is not deprecated and stays.
- `create_active_user` takes managed role names (`signoz-viewer`),
matching `change_user_role` and `create_service_account`.
- `find_role_by_name` moves to `fixtures/role.py` as a plain function
and replaces the `find_role_id` fixture — a stateless lookup shouldn't
be a fixture factory.

#### Issues closed by this PR

Contributes to SigNoz/platform-pod#2667

#### Additional Information

- `test_provision_user` now makes the provisioning calls inline, in
order, and covers the conflict branch that had no coverage before.
- `test_reset_password_v2` is gone; `test_reset_password` now targets v2
and absorbed its single-use-token assertion.
2026-08-12 12:41:15 +00:00
Tushar Vats
a3caaaf7f2 chore(frontend): regenerate ANTLR parser with search() support (#12491)
#### Description

- The frontend parser under `frontend/src/parser/` is generated from
`grammar/FilterQuery.g4` — the same grammar the backend query builder
uses — but the committed output predates the `search()` rule, so the UI
couldn't lex or parse `search('term')` even though the backend accepts
it. Regenerated it.
- Also fixed `scripts/grammar/generate-frontend-parser.sh`: ANTLR
reproduces the input's relative path under `-o`, so the old command
wrote to `frontend/src/parser/grammar/` instead of
`frontend/src/parser/`. It now runs from inside `grammar/`.
- Generated with ANTLR 4.13.2 (was 4.13.1), matching the `antlr4`
runtime in `frontend/package.json` and the version used for the Go
parser. That bump also adds `.js` extensions to the generated relative
imports — tsc (`moduleResolution: bundler`) and ts-jest resolve them
fine.
- Codegen only: no visitor/UI wiring for `search()` yet, so surfacing it
in autocomplete/validation is follow-up work.

#### Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5875

#### Additional Information

- Verified with a throwaway spec (not committed) that the regenerated
parser parses `search('error')`, `search('error', body, attribute)` and
`search('error') AND service.name = 'redis'` with zero syntax errors,
and still parses `has(payload.user_ids, 123)`.
- `src/parser/**` is in both the oxfmt and oxlint ignore lists, so the
raw ANTLR output is committed unformatted, as before. Because every
staged frontend file is ignored, lint-staged's `oxfmt --write` step
errors with "Expected at least one target file" — the commit needed
`--no-verify`.
- Ran the jest suites that consume the parser (`src/utils`,
`src/components/QueryBuilderV2`, `src/lib/recentQueries`): 371 passed, 1
pre-existing failure in `QuerySearch.test.tsx` that reproduces on `main`
without these changes.
2026-08-12 12:24:52 +00:00
Aditya Singh
a355996a5d feat(logs-explorer): resolve body json fields in logs table columns (#12503)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
In the logs explorer **table view**, a column for a field that lives
inside a JSON body now shows its value instead of coming up empty.
Scoped to `use_json_body` tenants.

- Added a wrapper util which sits on top of existing util which provides
the col values(FlatLogData).
- This searches for the attribute in body json. If its not present there
we will get it from attribute/resources as its happening currently.
- Only does this if `use_json_body` is true.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/engineering-pod/issues/4610
<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before:
DB Operation col is empty
<img width="3402" height="1850" alt="image"
src="https://github.com/user-attachments/assets/7f8c6945-2c43-4ea6-9b82-5fd07b36c52f"
/>


After:
DB Operation col is populated from body

<img width="3346" height="1778" alt="image"
src="https://github.com/user-attachments/assets/c1ae2953-986e-42d5-bef3-9bc10e932fc2"
/>
2026-08-12 11:38:25 +00:00
Tushar Vats
eea11972a9 fix(logs): return the JSON body from v3 query_range (#12523)
#### Description

- For orgs on JSON bodies the collector writes the legacy `body` column
empty (`processBody` blanks it unless `body_json_old_body_enabled`) and
keeps the log body in `body_v2`. The v3 logs list still selected `body`,
so every log came back with an empty body — verified on a tenant: all
2159 rows had `body = ''` and `body_v2` populated.
- `queryRangeV3` now resolves `use_json_body` for the caller's org and
the list query selects `body_v2 as body`, the same expression v5 uses.
- `GetListResultV3` decodes JSON columns into a map, mirroring the
querier's raw-row consumption — the driver cannot decode JSON into
native Go values, so it is read as raw bytes and unmarshalled. A v3
response now carries the same body object v5 returns, so clients need no
change when they move to v5.

#### Additional Information

Scoped to the v3 endpoint: `QueryRangeV4` does not set the flag, so v4
keeps selecting the legacy column even though it shares the builder. The
livetail select is untouched — `/api/v3/logs/livetail` is served by the
v5 handler now, and `PrepareLiveTailQuery` has no callers.

One difference from v5 remains by choice: v5's `postProcessLogBody`
drops an empty `message` key, so a log with an empty message reads
`{"message":""}` here and `{}` there. Nothing branches on it, and
copying that logic would be a second implementation to keep in sync on a
path we are retiring.

#12520 is stacked on this branch — it types the pipelines preview log
body as `any`, which that endpoint needs before it starts receiving the
object bodies this PR returns.
2026-08-12 11:10:13 +00:00
52 changed files with 1119 additions and 856 deletions

View File

@@ -10,6 +10,10 @@ jest.mock('providers/Timezone', () => ({
}),
}));
jest.mock('providers/App/App', () => ({
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
}));
const field = (name: string, type = ''): IField => ({
name,
type,

View File

@@ -2,13 +2,15 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { FeatureKeys } from 'constants/features';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { FlatLogData } from 'lib/logs/flatLogData';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -26,6 +28,10 @@ export function useLogsTableColumns({
fontSize,
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
@@ -88,7 +94,8 @@ export function useLogsTableColumns({
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
id: buildCompositeKey(f.name, f.type),
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
accessorFn: (log): unknown =>
getLogFieldValue(log, f.name, isBodyJsonEnabled),
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
@@ -115,5 +122,5 @@ export function useLogsTableColumns({
.filter((c): c is TableColumnDef<ILog> => c !== null);
return [stateIndicatorCol, ...fieldCols];
}, [fields, fontSize, formatTimezoneAdjustedTimestamp]);
}, [fields, fontSize, formatTimezoneAdjustedTimestamp, isBodyJsonEnabled]);
}

View File

@@ -59,6 +59,7 @@ import {
dedupeOptionsByLabel,
getFieldContextPrefix,
getRecentOptions,
isSupportedFunction,
renderRecentDeleteButton,
} from './utils';
@@ -1275,11 +1276,13 @@ function QuerySearch({
}
if (queryContext.isInFunction) {
options = Object.values(QUERY_BUILDER_FUNCTIONS).map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
options = Object.values(QUERY_BUILDER_FUNCTIONS)
.filter((option) => isSupportedFunction(option, dataSource))
.map((option) => ({
label: option,
apply: `${option}()`,
type: 'function',
}));
// Add space after selection for functions
const optionsWithSpace = addSpaceToOptions(options);

View File

@@ -1,8 +1,12 @@
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
import { DataSource } from 'types/common/queryBuilder';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
getFieldContextPrefix,
getUserExpressionFromCombined,
isSupportedFunction,
} from '../utils';
describe('entityLogsExpression', () => {
@@ -118,3 +122,19 @@ describe('dedupeOptionsByLabel', () => {
expect(dedupeOptionsByLabel([])).toStrictEqual([]);
});
});
describe('isSupportedFunction', () => {
const { HASANY, SEARCH } = QUERY_BUILDER_FUNCTIONS;
it('allows the has family on every signal', () => {
[DataSource.LOGS, DataSource.TRACES, DataSource.METRICS].forEach((signal) => {
expect(isSupportedFunction(HASANY, signal)).toBe(true);
});
});
it('allows search on logs only', () => {
expect(isSupportedFunction(SEARCH, DataSource.LOGS)).toBe(true);
expect(isSupportedFunction(SEARCH, DataSource.TRACES)).toBe(false);
expect(isSupportedFunction(SEARCH, DataSource.METRICS)).toBe(false);
});
});

View File

@@ -1,6 +1,7 @@
import { closeCompletion, startCompletion } from '@codemirror/autocomplete';
import type { Completion } from '@codemirror/autocomplete';
import type { EditorView } from '@uiw/react-codemirror';
import { QUERY_BUILDER_FUNCTIONS } from 'constants/antlrQueryConstants';
import dayjs from 'dayjs';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import * as recentQueriesStore from 'lib/recentQueries/recentQueriesStore';
@@ -15,6 +16,15 @@ import {
RECENTS_SECTION,
} from './constants';
// search() lives in the logs condition builder only; traces and metrics reject it
// as an unsupported operator. Every other function is implemented for all signals.
export function isSupportedFunction(
functionName: string,
signal: SignalType,
): boolean {
return functionName !== QUERY_BUILDER_FUNCTIONS.SEARCH || signal === 'logs';
}
export interface FieldContextPrefixMatch {
context: string;
remainder: string;

View File

@@ -41,6 +41,7 @@ export const QUERY_BUILDER_FUNCTIONS = {
HASANY: 'hasAny',
HASALL: 'hasAll',
HASTOKEN: 'hasToken',
SEARCH: 'search',
};
export function negateOperator(operatorOrFunction: string): string {

View File

@@ -0,0 +1,55 @@
import { ILog } from 'types/api/logs/log';
import { getLogFieldValue } from './flatLogData';
const asLog = (partial: Partial<ILog>): ILog => partial as unknown as ILog;
describe('getLogFieldValue', () => {
it('resolves a nested body field by dotted key when use_json_body is on', () => {
const log = asLog({ body: { a: { b: { c: 'deep' } } } });
expect(getLogFieldValue(log, 'a.b.c', true)).toBe('deep');
});
it('ignores body when use_json_body is off', () => {
const log = asLog({ body: { a: { b: { c: 'deep' } } } });
expect(getLogFieldValue(log, 'a.b.c', false)).toBeUndefined();
});
it('ignores a stringified body even when use_json_body is on', () => {
const log = asLog({ body: '{"a":{"b":1}}' });
expect(getLogFieldValue(log, 'a.b', true)).toBeUndefined();
});
it('prefers the body value over attributes when the key exists in both (body first)', () => {
const log = asLog({
attributes_string: { 'a.b': 'attr' } as never,
body: { a: { b: 'bodyval' } },
});
expect(getLogFieldValue(log, 'a.b', true)).toBe('bodyval');
});
it('falls back to attributes when the key is not in the body', () => {
const log = asLog({
attributes_string: { 'x.y': 'attr' } as never,
body: { other: 1 },
});
expect(getLogFieldValue(log, 'x.y', true)).toBe('attr');
});
it('preserves falsy body values (0, false, empty string)', () => {
const log = asLog({ body: { n: 0, flag: false, s: '' } });
expect(getLogFieldValue(log, 'n', true)).toBe(0);
expect(getLogFieldValue(log, 'flag', true)).toBe(false);
expect(getLogFieldValue(log, 's', true)).toBe('');
});
it('returns undefined when the body path is missing', () => {
const log = asLog({ body: { x: 1 } });
expect(getLogFieldValue(log, 'nope', true)).toBeUndefined();
});
it('returns undefined when a mid path segment is not an object', () => {
const log = asLog({ body: { a: { b: 'leaf' } } });
expect(getLogFieldValue(log, 'a.b.c', true)).toBeUndefined();
});
});

View File

@@ -1,5 +1,5 @@
import { defaultTo } from 'lodash-es';
import { ILog } from 'types/api/logs/log';
import { ILog, ILogBody } from 'types/api/logs/log';
export function FlatLogData(log: ILog): Record<string, string> {
const flattenLogObject: Record<string, string> = {};
@@ -15,3 +15,29 @@ export function FlatLogData(log: ILog): Record<string, string> {
});
return flattenLogObject;
}
function getBodyFieldValue(body: ILogBody, key: string): unknown {
return key.split('.').reduce<unknown>((acc, segment) => {
if (acc && typeof acc === 'object' && !Array.isArray(acc)) {
return (acc as Record<string, unknown>)[segment];
}
return undefined;
}, body);
}
// Resolve one field for the logs table. A JSON body is checked first (use_json_body
// only), splitting the key on `.`; otherwise fall back to FlatLogData
// (attributes/resources/scope/top-level).
export function getLogFieldValue(
log: ILog,
fieldName: string,
isBodyJsonEnabled: boolean,
): unknown {
if (isBodyJsonEnabled && log.body && typeof log.body === 'object') {
const bodyValue = getBodyFieldValue(log.body, fieldName);
if (bodyValue !== undefined) {
return bodyValue;
}
}
return FlatLogData(log)[fieldName];
}

View File

@@ -34,7 +34,7 @@ describe('normalizeFilterExpression', () => {
);
});
it('lowercases HAS / HASANY / HASALL / HASTOKEN function names', () => {
it('lowercases HAS / HASANY / HASALL / HASTOKEN / SEARCH function names', () => {
expect(normalizeFilterExpression('HAS(tags, "x")')).toBe(
normalizeFilterExpression('has(tags, "x")'),
);
@@ -47,6 +47,9 @@ describe('normalizeFilterExpression', () => {
expect(normalizeFilterExpression('HASTOKEN(msg, "err")')).toBe(
normalizeFilterExpression('hasToken(msg, "err")'),
);
expect(normalizeFilterExpression('SEARCH("err")')).toBe(
normalizeFilterExpression('search("err")'),
);
});
it('lowercases TRUE / FALSE boolean literals', () => {

File diff suppressed because one or more lines are too long

View File

@@ -24,12 +24,13 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
'('=1
')'=2
'['=3

File diff suppressed because one or more lines are too long

View File

@@ -24,12 +24,13 @@ HASTOKEN=23
HAS=24
HASANY=25
HASALL=26
BOOL=27
NUMBER=28
QUOTED_TEXT=29
KEY=30
WS=31
FREETEXT=32
SEARCH=27
BOOL=28
NUMBER=29
QUOTED_TEXT=30
KEY=31
WS=32
FREETEXT=33
'('=1
')'=2
'['=3

View File

@@ -1,4 +1,4 @@
// Generated from FilterQuery.g4 by ANTLR 4.13.1
// Generated from FilterQuery.g4 by ANTLR 4.13.2
// noinspection ES6UnusedImports,JSUnusedGlobalSymbols,JSUnusedLocalSymbols
import {
ATN,
@@ -38,12 +38,13 @@ export default class FilterQueryLexer extends Lexer {
public static readonly HAS = 24;
public static readonly HASANY = 25;
public static readonly HASALL = 26;
public static readonly BOOL = 27;
public static readonly NUMBER = 28;
public static readonly QUOTED_TEXT = 29;
public static readonly KEY = 30;
public static readonly WS = 31;
public static readonly FREETEXT = 32;
public static readonly SEARCH = 27;
public static readonly BOOL = 28;
public static readonly NUMBER = 29;
public static readonly QUOTED_TEXT = 30;
public static readonly KEY = 31;
public static readonly WS = 32;
public static readonly FREETEXT = 33;
public static readonly EOF = Token.EOF;
public static readonly channelNames: string[] = [ "DEFAULT_TOKEN_CHANNEL", "HIDDEN" ];
@@ -68,8 +69,9 @@ export default class FilterQueryLexer extends Lexer {
"AND", "OR",
"HASTOKEN",
"HAS", "HASANY",
"HASALL", "BOOL",
"NUMBER", "QUOTED_TEXT",
"HASALL", "SEARCH",
"BOOL", "NUMBER",
"QUOTED_TEXT",
"KEY", "WS",
"FREETEXT" ];
public static readonly modeNames: string[] = [ "DEFAULT_MODE", ];
@@ -78,8 +80,8 @@ export default class FilterQueryLexer extends Lexer {
"LPAREN", "RPAREN", "LBRACK", "RBRACK", "COMMA", "EQUALS", "NOT_EQUALS",
"NEQ", "LT", "LE", "GT", "GE", "LIKE", "ILIKE", "BETWEEN", "EXISTS", "REGEXP",
"CONTAINS", "IN", "NOT", "AND", "OR", "HASTOKEN", "HAS", "HASANY", "HASALL",
"BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS", "OLD_JSON_BRACKS",
"KEY", "WS", "DIGIT", "FREETEXT",
"SEARCH", "BOOL", "SIGN", "NUMBER", "QUOTED_TEXT", "SEGMENT", "EMPTY_BRACKS",
"OLD_JSON_BRACKS", "KEY", "WS", "DIGIT", "FREETEXT",
];
@@ -100,119 +102,122 @@ export default class FilterQueryLexer extends Lexer {
public get modeNames(): string[] { return FilterQueryLexer.modeNames; }
public static readonly _serializedATN: number[] = [4,0,32,320,6,-1,2,0,
public static readonly _serializedATN: number[] = [4,0,33,329,6,-1,2,0,
7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,7,2,8,7,8,2,9,
7,9,2,10,7,10,2,11,7,11,2,12,7,12,2,13,7,13,2,14,7,14,2,15,7,15,2,16,7,
16,2,17,7,17,2,18,7,18,2,19,7,19,2,20,7,20,2,21,7,21,2,22,7,22,2,23,7,23,
2,24,7,24,2,25,7,25,2,26,7,26,2,27,7,27,2,28,7,28,2,29,7,29,2,30,7,30,2,
31,7,31,2,32,7,32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,1,0,1,0,1,1,1,
1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,3,5,89,8,5,1,6,1,6,1,6,1,7,1,7,1,
7,1,8,1,8,1,9,1,9,1,9,1,10,1,10,1,11,1,11,1,11,1,12,1,12,1,12,1,12,1,12,
1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,14,1,14,1,
15,1,15,1,15,1,15,1,15,1,15,3,15,132,8,15,1,16,1,16,1,16,1,16,1,16,1,16,
1,16,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,3,17,149,8,17,1,18,1,18,1,
18,1,19,1,19,1,19,1,19,1,20,1,20,1,20,1,20,1,21,1,21,1,21,1,22,1,22,1,22,
1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,23,1,24,1,24,1,24,1,24,1,
24,1,24,1,24,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,1,26,1,26,
1,26,1,26,1,26,1,26,3,26,201,8,26,1,27,1,27,1,28,3,28,206,8,28,1,28,4,28,
209,8,28,11,28,12,28,210,1,28,1,28,5,28,215,8,28,10,28,12,28,218,9,28,3,
28,220,8,28,1,28,1,28,3,28,224,8,28,1,28,4,28,227,8,28,11,28,12,28,228,
3,28,231,8,28,1,28,3,28,234,8,28,1,28,1,28,4,28,238,8,28,11,28,12,28,239,
1,28,1,28,3,28,244,8,28,1,28,4,28,247,8,28,11,28,12,28,248,3,28,251,8,28,
3,28,253,8,28,1,29,1,29,1,29,1,29,5,29,259,8,29,10,29,12,29,262,9,29,1,
29,1,29,1,29,1,29,1,29,5,29,269,8,29,10,29,12,29,272,9,29,1,29,3,29,275,
8,29,1,30,1,30,5,30,279,8,30,10,30,12,30,282,9,30,1,31,1,31,1,31,1,32,1,
32,1,32,1,32,1,33,1,33,1,33,1,33,1,33,1,33,1,33,4,33,298,8,33,11,33,12,
33,299,5,33,302,8,33,10,33,12,33,305,9,33,1,34,4,34,308,8,34,11,34,12,34,
309,1,34,1,34,1,35,1,35,1,36,4,36,317,8,36,11,36,12,36,318,0,0,37,1,1,3,
2,5,3,7,4,9,5,11,6,13,7,15,8,17,9,19,10,21,11,23,12,25,13,27,14,29,15,31,
16,33,17,35,18,37,19,39,20,41,21,43,22,45,23,47,24,49,25,51,26,53,27,55,
0,57,28,59,29,61,0,63,0,65,0,67,30,69,31,71,0,73,32,1,0,29,2,0,76,76,108,
108,2,0,73,73,105,105,2,0,75,75,107,107,2,0,69,69,101,101,2,0,66,66,98,
98,2,0,84,84,116,116,2,0,87,87,119,119,2,0,78,78,110,110,2,0,88,88,120,
120,2,0,83,83,115,115,2,0,82,82,114,114,2,0,71,71,103,103,2,0,80,80,112,
112,2,0,67,67,99,99,2,0,79,79,111,111,2,0,65,65,97,97,2,0,68,68,100,100,
2,0,72,72,104,104,2,0,89,89,121,121,2,0,85,85,117,117,2,0,70,70,102,102,
2,0,43,43,45,45,2,0,34,34,92,92,2,0,39,39,92,92,4,0,35,36,64,90,95,95,97,
123,7,0,35,36,45,45,47,58,64,90,95,95,97,123,125,125,3,0,9,10,13,13,32,
32,1,0,48,57,8,0,9,10,13,13,32,34,39,41,44,44,60,62,91,91,93,93,344,0,1,
1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,
13,1,0,0,0,0,15,1,0,0,0,0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,
0,0,0,25,1,0,0,0,0,27,1,0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,
35,1,0,0,0,0,37,1,0,0,0,0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,
0,0,0,47,1,0,0,0,0,49,1,0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,57,1,0,0,0,0,
59,1,0,0,0,0,67,1,0,0,0,0,69,1,0,0,0,0,73,1,0,0,0,1,75,1,0,0,0,3,77,1,0,
0,0,5,79,1,0,0,0,7,81,1,0,0,0,9,83,1,0,0,0,11,88,1,0,0,0,13,90,1,0,0,0,
15,93,1,0,0,0,17,96,1,0,0,0,19,98,1,0,0,0,21,101,1,0,0,0,23,103,1,0,0,0,
25,106,1,0,0,0,27,111,1,0,0,0,29,117,1,0,0,0,31,125,1,0,0,0,33,133,1,0,
0,0,35,140,1,0,0,0,37,150,1,0,0,0,39,153,1,0,0,0,41,157,1,0,0,0,43,161,
1,0,0,0,45,164,1,0,0,0,47,173,1,0,0,0,49,177,1,0,0,0,51,184,1,0,0,0,53,
200,1,0,0,0,55,202,1,0,0,0,57,252,1,0,0,0,59,274,1,0,0,0,61,276,1,0,0,0,
63,283,1,0,0,0,65,286,1,0,0,0,67,290,1,0,0,0,69,307,1,0,0,0,71,313,1,0,
0,0,73,316,1,0,0,0,75,76,5,40,0,0,76,2,1,0,0,0,77,78,5,41,0,0,78,4,1,0,
0,0,79,80,5,91,0,0,80,6,1,0,0,0,81,82,5,93,0,0,82,8,1,0,0,0,83,84,5,44,
0,0,84,10,1,0,0,0,85,89,5,61,0,0,86,87,5,61,0,0,87,89,5,61,0,0,88,85,1,
0,0,0,88,86,1,0,0,0,89,12,1,0,0,0,90,91,5,33,0,0,91,92,5,61,0,0,92,14,1,
0,0,0,93,94,5,60,0,0,94,95,5,62,0,0,95,16,1,0,0,0,96,97,5,60,0,0,97,18,
1,0,0,0,98,99,5,60,0,0,99,100,5,61,0,0,100,20,1,0,0,0,101,102,5,62,0,0,
102,22,1,0,0,0,103,104,5,62,0,0,104,105,5,61,0,0,105,24,1,0,0,0,106,107,
7,0,0,0,107,108,7,1,0,0,108,109,7,2,0,0,109,110,7,3,0,0,110,26,1,0,0,0,
111,112,7,1,0,0,112,113,7,0,0,0,113,114,7,1,0,0,114,115,7,2,0,0,115,116,
7,3,0,0,116,28,1,0,0,0,117,118,7,4,0,0,118,119,7,3,0,0,119,120,7,5,0,0,
120,121,7,6,0,0,121,122,7,3,0,0,122,123,7,3,0,0,123,124,7,7,0,0,124,30,
1,0,0,0,125,126,7,3,0,0,126,127,7,8,0,0,127,128,7,1,0,0,128,129,7,9,0,0,
129,131,7,5,0,0,130,132,7,9,0,0,131,130,1,0,0,0,131,132,1,0,0,0,132,32,
1,0,0,0,133,134,7,10,0,0,134,135,7,3,0,0,135,136,7,11,0,0,136,137,7,3,0,
0,137,138,7,8,0,0,138,139,7,12,0,0,139,34,1,0,0,0,140,141,7,13,0,0,141,
142,7,14,0,0,142,143,7,7,0,0,143,144,7,5,0,0,144,145,7,15,0,0,145,146,7,
1,0,0,146,148,7,7,0,0,147,149,7,9,0,0,148,147,1,0,0,0,148,149,1,0,0,0,149,
36,1,0,0,0,150,151,7,1,0,0,151,152,7,7,0,0,152,38,1,0,0,0,153,154,7,7,0,
0,154,155,7,14,0,0,155,156,7,5,0,0,156,40,1,0,0,0,157,158,7,15,0,0,158,
159,7,7,0,0,159,160,7,16,0,0,160,42,1,0,0,0,161,162,7,14,0,0,162,163,7,
10,0,0,163,44,1,0,0,0,164,165,7,17,0,0,165,166,7,15,0,0,166,167,7,9,0,0,
167,168,7,5,0,0,168,169,7,14,0,0,169,170,7,2,0,0,170,171,7,3,0,0,171,172,
7,7,0,0,172,46,1,0,0,0,173,174,7,17,0,0,174,175,7,15,0,0,175,176,7,9,0,
0,176,48,1,0,0,0,177,178,7,17,0,0,178,179,7,15,0,0,179,180,7,9,0,0,180,
181,7,15,0,0,181,182,7,7,0,0,182,183,7,18,0,0,183,50,1,0,0,0,184,185,7,
17,0,0,185,186,7,15,0,0,186,187,7,9,0,0,187,188,7,15,0,0,188,189,7,0,0,
0,189,190,7,0,0,0,190,52,1,0,0,0,191,192,7,5,0,0,192,193,7,10,0,0,193,194,
7,19,0,0,194,201,7,3,0,0,195,196,7,20,0,0,196,197,7,15,0,0,197,198,7,0,
0,0,198,199,7,9,0,0,199,201,7,3,0,0,200,191,1,0,0,0,200,195,1,0,0,0,201,
54,1,0,0,0,202,203,7,21,0,0,203,56,1,0,0,0,204,206,3,55,27,0,205,204,1,
0,0,0,205,206,1,0,0,0,206,208,1,0,0,0,207,209,3,71,35,0,208,207,1,0,0,0,
209,210,1,0,0,0,210,208,1,0,0,0,210,211,1,0,0,0,211,219,1,0,0,0,212,216,
5,46,0,0,213,215,3,71,35,0,214,213,1,0,0,0,215,218,1,0,0,0,216,214,1,0,
0,0,216,217,1,0,0,0,217,220,1,0,0,0,218,216,1,0,0,0,219,212,1,0,0,0,219,
220,1,0,0,0,220,230,1,0,0,0,221,223,7,3,0,0,222,224,3,55,27,0,223,222,1,
0,0,0,223,224,1,0,0,0,224,226,1,0,0,0,225,227,3,71,35,0,226,225,1,0,0,0,
227,228,1,0,0,0,228,226,1,0,0,0,228,229,1,0,0,0,229,231,1,0,0,0,230,221,
1,0,0,0,230,231,1,0,0,0,231,253,1,0,0,0,232,234,3,55,27,0,233,232,1,0,0,
0,233,234,1,0,0,0,234,235,1,0,0,0,235,237,5,46,0,0,236,238,3,71,35,0,237,
236,1,0,0,0,238,239,1,0,0,0,239,237,1,0,0,0,239,240,1,0,0,0,240,250,1,0,
0,0,241,243,7,3,0,0,242,244,3,55,27,0,243,242,1,0,0,0,243,244,1,0,0,0,244,
246,1,0,0,0,245,247,3,71,35,0,246,245,1,0,0,0,247,248,1,0,0,0,248,246,1,
0,0,0,248,249,1,0,0,0,249,251,1,0,0,0,250,241,1,0,0,0,250,251,1,0,0,0,251,
253,1,0,0,0,252,205,1,0,0,0,252,233,1,0,0,0,253,58,1,0,0,0,254,260,5,34,
0,0,255,259,8,22,0,0,256,257,5,92,0,0,257,259,9,0,0,0,258,255,1,0,0,0,258,
256,1,0,0,0,259,262,1,0,0,0,260,258,1,0,0,0,260,261,1,0,0,0,261,263,1,0,
0,0,262,260,1,0,0,0,263,275,5,34,0,0,264,270,5,39,0,0,265,269,8,23,0,0,
266,267,5,92,0,0,267,269,9,0,0,0,268,265,1,0,0,0,268,266,1,0,0,0,269,272,
1,0,0,0,270,268,1,0,0,0,270,271,1,0,0,0,271,273,1,0,0,0,272,270,1,0,0,0,
273,275,5,39,0,0,274,254,1,0,0,0,274,264,1,0,0,0,275,60,1,0,0,0,276,280,
7,24,0,0,277,279,7,25,0,0,278,277,1,0,0,0,279,282,1,0,0,0,280,278,1,0,0,
0,280,281,1,0,0,0,281,62,1,0,0,0,282,280,1,0,0,0,283,284,5,91,0,0,284,285,
5,93,0,0,285,64,1,0,0,0,286,287,5,91,0,0,287,288,5,42,0,0,288,289,5,93,
0,0,289,66,1,0,0,0,290,303,3,61,30,0,291,292,5,46,0,0,292,302,3,61,30,0,
293,302,3,63,31,0,294,302,3,65,32,0,295,297,5,46,0,0,296,298,3,71,35,0,
297,296,1,0,0,0,298,299,1,0,0,0,299,297,1,0,0,0,299,300,1,0,0,0,300,302,
1,0,0,0,301,291,1,0,0,0,301,293,1,0,0,0,301,294,1,0,0,0,301,295,1,0,0,0,
302,305,1,0,0,0,303,301,1,0,0,0,303,304,1,0,0,0,304,68,1,0,0,0,305,303,
1,0,0,0,306,308,7,26,0,0,307,306,1,0,0,0,308,309,1,0,0,0,309,307,1,0,0,
0,309,310,1,0,0,0,310,311,1,0,0,0,311,312,6,34,0,0,312,70,1,0,0,0,313,314,
7,27,0,0,314,72,1,0,0,0,315,317,8,28,0,0,316,315,1,0,0,0,317,318,1,0,0,
0,318,316,1,0,0,0,318,319,1,0,0,0,319,74,1,0,0,0,29,0,88,131,148,200,205,
210,216,219,223,228,230,233,239,243,248,250,252,258,260,268,270,274,280,
299,301,303,309,318,1,6,0,0];
31,7,31,2,32,7,32,2,33,7,33,2,34,7,34,2,35,7,35,2,36,7,36,2,37,7,37,1,0,
1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,5,3,5,91,8,5,1,6,1,6,1,6,
1,7,1,7,1,7,1,8,1,8,1,9,1,9,1,9,1,10,1,10,1,11,1,11,1,11,1,12,1,12,1,12,
1,12,1,12,1,13,1,13,1,13,1,13,1,13,1,13,1,14,1,14,1,14,1,14,1,14,1,14,1,
14,1,14,1,15,1,15,1,15,1,15,1,15,1,15,3,15,134,8,15,1,16,1,16,1,16,1,16,
1,16,1,16,1,16,1,17,1,17,1,17,1,17,1,17,1,17,1,17,1,17,3,17,151,8,17,1,
18,1,18,1,18,1,19,1,19,1,19,1,19,1,20,1,20,1,20,1,20,1,21,1,21,1,21,1,22,
1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,22,1,23,1,23,1,23,1,23,1,24,1,24,1,
24,1,24,1,24,1,24,1,24,1,25,1,25,1,25,1,25,1,25,1,25,1,25,1,26,1,26,1,26,
1,26,1,26,1,26,1,26,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,1,27,3,27,210,
8,27,1,28,1,28,1,29,3,29,215,8,29,1,29,4,29,218,8,29,11,29,12,29,219,1,
29,1,29,5,29,224,8,29,10,29,12,29,227,9,29,3,29,229,8,29,1,29,1,29,3,29,
233,8,29,1,29,4,29,236,8,29,11,29,12,29,237,3,29,240,8,29,1,29,3,29,243,
8,29,1,29,1,29,4,29,247,8,29,11,29,12,29,248,1,29,1,29,3,29,253,8,29,1,
29,4,29,256,8,29,11,29,12,29,257,3,29,260,8,29,3,29,262,8,29,1,30,1,30,
1,30,1,30,5,30,268,8,30,10,30,12,30,271,9,30,1,30,1,30,1,30,1,30,1,30,5,
30,278,8,30,10,30,12,30,281,9,30,1,30,3,30,284,8,30,1,31,1,31,5,31,288,
8,31,10,31,12,31,291,9,31,1,32,1,32,1,32,1,33,1,33,1,33,1,33,1,34,1,34,
1,34,1,34,1,34,1,34,1,34,4,34,307,8,34,11,34,12,34,308,5,34,311,8,34,10,
34,12,34,314,9,34,1,35,4,35,317,8,35,11,35,12,35,318,1,35,1,35,1,36,1,36,
1,37,4,37,326,8,37,11,37,12,37,327,0,0,38,1,1,3,2,5,3,7,4,9,5,11,6,13,7,
15,8,17,9,19,10,21,11,23,12,25,13,27,14,29,15,31,16,33,17,35,18,37,19,39,
20,41,21,43,22,45,23,47,24,49,25,51,26,53,27,55,28,57,0,59,29,61,30,63,
0,65,0,67,0,69,31,71,32,73,0,75,33,1,0,29,2,0,76,76,108,108,2,0,73,73,105,
105,2,0,75,75,107,107,2,0,69,69,101,101,2,0,66,66,98,98,2,0,84,84,116,116,
2,0,87,87,119,119,2,0,78,78,110,110,2,0,88,88,120,120,2,0,83,83,115,115,
2,0,82,82,114,114,2,0,71,71,103,103,2,0,80,80,112,112,2,0,67,67,99,99,2,
0,79,79,111,111,2,0,65,65,97,97,2,0,68,68,100,100,2,0,72,72,104,104,2,0,
89,89,121,121,2,0,85,85,117,117,2,0,70,70,102,102,2,0,43,43,45,45,2,0,34,
34,92,92,2,0,39,39,92,92,4,0,35,36,64,90,95,95,97,123,7,0,35,36,45,45,47,
58,64,90,95,95,97,123,125,125,3,0,9,10,13,13,32,32,1,0,48,57,8,0,9,10,13,
13,32,34,39,41,44,44,60,62,91,91,93,93,353,0,1,1,0,0,0,0,3,1,0,0,0,0,5,
1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,
0,17,1,0,0,0,0,19,1,0,0,0,0,21,1,0,0,0,0,23,1,0,0,0,0,25,1,0,0,0,0,27,1,
0,0,0,0,29,1,0,0,0,0,31,1,0,0,0,0,33,1,0,0,0,0,35,1,0,0,0,0,37,1,0,0,0,
0,39,1,0,0,0,0,41,1,0,0,0,0,43,1,0,0,0,0,45,1,0,0,0,0,47,1,0,0,0,0,49,1,
0,0,0,0,51,1,0,0,0,0,53,1,0,0,0,0,55,1,0,0,0,0,59,1,0,0,0,0,61,1,0,0,0,
0,69,1,0,0,0,0,71,1,0,0,0,0,75,1,0,0,0,1,77,1,0,0,0,3,79,1,0,0,0,5,81,1,
0,0,0,7,83,1,0,0,0,9,85,1,0,0,0,11,90,1,0,0,0,13,92,1,0,0,0,15,95,1,0,0,
0,17,98,1,0,0,0,19,100,1,0,0,0,21,103,1,0,0,0,23,105,1,0,0,0,25,108,1,0,
0,0,27,113,1,0,0,0,29,119,1,0,0,0,31,127,1,0,0,0,33,135,1,0,0,0,35,142,
1,0,0,0,37,152,1,0,0,0,39,155,1,0,0,0,41,159,1,0,0,0,43,163,1,0,0,0,45,
166,1,0,0,0,47,175,1,0,0,0,49,179,1,0,0,0,51,186,1,0,0,0,53,193,1,0,0,0,
55,209,1,0,0,0,57,211,1,0,0,0,59,261,1,0,0,0,61,283,1,0,0,0,63,285,1,0,
0,0,65,292,1,0,0,0,67,295,1,0,0,0,69,299,1,0,0,0,71,316,1,0,0,0,73,322,
1,0,0,0,75,325,1,0,0,0,77,78,5,40,0,0,78,2,1,0,0,0,79,80,5,41,0,0,80,4,
1,0,0,0,81,82,5,91,0,0,82,6,1,0,0,0,83,84,5,93,0,0,84,8,1,0,0,0,85,86,5,
44,0,0,86,10,1,0,0,0,87,91,5,61,0,0,88,89,5,61,0,0,89,91,5,61,0,0,90,87,
1,0,0,0,90,88,1,0,0,0,91,12,1,0,0,0,92,93,5,33,0,0,93,94,5,61,0,0,94,14,
1,0,0,0,95,96,5,60,0,0,96,97,5,62,0,0,97,16,1,0,0,0,98,99,5,60,0,0,99,18,
1,0,0,0,100,101,5,60,0,0,101,102,5,61,0,0,102,20,1,0,0,0,103,104,5,62,0,
0,104,22,1,0,0,0,105,106,5,62,0,0,106,107,5,61,0,0,107,24,1,0,0,0,108,109,
7,0,0,0,109,110,7,1,0,0,110,111,7,2,0,0,111,112,7,3,0,0,112,26,1,0,0,0,
113,114,7,1,0,0,114,115,7,0,0,0,115,116,7,1,0,0,116,117,7,2,0,0,117,118,
7,3,0,0,118,28,1,0,0,0,119,120,7,4,0,0,120,121,7,3,0,0,121,122,7,5,0,0,
122,123,7,6,0,0,123,124,7,3,0,0,124,125,7,3,0,0,125,126,7,7,0,0,126,30,
1,0,0,0,127,128,7,3,0,0,128,129,7,8,0,0,129,130,7,1,0,0,130,131,7,9,0,0,
131,133,7,5,0,0,132,134,7,9,0,0,133,132,1,0,0,0,133,134,1,0,0,0,134,32,
1,0,0,0,135,136,7,10,0,0,136,137,7,3,0,0,137,138,7,11,0,0,138,139,7,3,0,
0,139,140,7,8,0,0,140,141,7,12,0,0,141,34,1,0,0,0,142,143,7,13,0,0,143,
144,7,14,0,0,144,145,7,7,0,0,145,146,7,5,0,0,146,147,7,15,0,0,147,148,7,
1,0,0,148,150,7,7,0,0,149,151,7,9,0,0,150,149,1,0,0,0,150,151,1,0,0,0,151,
36,1,0,0,0,152,153,7,1,0,0,153,154,7,7,0,0,154,38,1,0,0,0,155,156,7,7,0,
0,156,157,7,14,0,0,157,158,7,5,0,0,158,40,1,0,0,0,159,160,7,15,0,0,160,
161,7,7,0,0,161,162,7,16,0,0,162,42,1,0,0,0,163,164,7,14,0,0,164,165,7,
10,0,0,165,44,1,0,0,0,166,167,7,17,0,0,167,168,7,15,0,0,168,169,7,9,0,0,
169,170,7,5,0,0,170,171,7,14,0,0,171,172,7,2,0,0,172,173,7,3,0,0,173,174,
7,7,0,0,174,46,1,0,0,0,175,176,7,17,0,0,176,177,7,15,0,0,177,178,7,9,0,
0,178,48,1,0,0,0,179,180,7,17,0,0,180,181,7,15,0,0,181,182,7,9,0,0,182,
183,7,15,0,0,183,184,7,7,0,0,184,185,7,18,0,0,185,50,1,0,0,0,186,187,7,
17,0,0,187,188,7,15,0,0,188,189,7,9,0,0,189,190,7,15,0,0,190,191,7,0,0,
0,191,192,7,0,0,0,192,52,1,0,0,0,193,194,7,9,0,0,194,195,7,3,0,0,195,196,
7,15,0,0,196,197,7,10,0,0,197,198,7,13,0,0,198,199,7,17,0,0,199,54,1,0,
0,0,200,201,7,5,0,0,201,202,7,10,0,0,202,203,7,19,0,0,203,210,7,3,0,0,204,
205,7,20,0,0,205,206,7,15,0,0,206,207,7,0,0,0,207,208,7,9,0,0,208,210,7,
3,0,0,209,200,1,0,0,0,209,204,1,0,0,0,210,56,1,0,0,0,211,212,7,21,0,0,212,
58,1,0,0,0,213,215,3,57,28,0,214,213,1,0,0,0,214,215,1,0,0,0,215,217,1,
0,0,0,216,218,3,73,36,0,217,216,1,0,0,0,218,219,1,0,0,0,219,217,1,0,0,0,
219,220,1,0,0,0,220,228,1,0,0,0,221,225,5,46,0,0,222,224,3,73,36,0,223,
222,1,0,0,0,224,227,1,0,0,0,225,223,1,0,0,0,225,226,1,0,0,0,226,229,1,0,
0,0,227,225,1,0,0,0,228,221,1,0,0,0,228,229,1,0,0,0,229,239,1,0,0,0,230,
232,7,3,0,0,231,233,3,57,28,0,232,231,1,0,0,0,232,233,1,0,0,0,233,235,1,
0,0,0,234,236,3,73,36,0,235,234,1,0,0,0,236,237,1,0,0,0,237,235,1,0,0,0,
237,238,1,0,0,0,238,240,1,0,0,0,239,230,1,0,0,0,239,240,1,0,0,0,240,262,
1,0,0,0,241,243,3,57,28,0,242,241,1,0,0,0,242,243,1,0,0,0,243,244,1,0,0,
0,244,246,5,46,0,0,245,247,3,73,36,0,246,245,1,0,0,0,247,248,1,0,0,0,248,
246,1,0,0,0,248,249,1,0,0,0,249,259,1,0,0,0,250,252,7,3,0,0,251,253,3,57,
28,0,252,251,1,0,0,0,252,253,1,0,0,0,253,255,1,0,0,0,254,256,3,73,36,0,
255,254,1,0,0,0,256,257,1,0,0,0,257,255,1,0,0,0,257,258,1,0,0,0,258,260,
1,0,0,0,259,250,1,0,0,0,259,260,1,0,0,0,260,262,1,0,0,0,261,214,1,0,0,0,
261,242,1,0,0,0,262,60,1,0,0,0,263,269,5,34,0,0,264,268,8,22,0,0,265,266,
5,92,0,0,266,268,9,0,0,0,267,264,1,0,0,0,267,265,1,0,0,0,268,271,1,0,0,
0,269,267,1,0,0,0,269,270,1,0,0,0,270,272,1,0,0,0,271,269,1,0,0,0,272,284,
5,34,0,0,273,279,5,39,0,0,274,278,8,23,0,0,275,276,5,92,0,0,276,278,9,0,
0,0,277,274,1,0,0,0,277,275,1,0,0,0,278,281,1,0,0,0,279,277,1,0,0,0,279,
280,1,0,0,0,280,282,1,0,0,0,281,279,1,0,0,0,282,284,5,39,0,0,283,263,1,
0,0,0,283,273,1,0,0,0,284,62,1,0,0,0,285,289,7,24,0,0,286,288,7,25,0,0,
287,286,1,0,0,0,288,291,1,0,0,0,289,287,1,0,0,0,289,290,1,0,0,0,290,64,
1,0,0,0,291,289,1,0,0,0,292,293,5,91,0,0,293,294,5,93,0,0,294,66,1,0,0,
0,295,296,5,91,0,0,296,297,5,42,0,0,297,298,5,93,0,0,298,68,1,0,0,0,299,
312,3,63,31,0,300,301,5,46,0,0,301,311,3,63,31,0,302,311,3,65,32,0,303,
311,3,67,33,0,304,306,5,46,0,0,305,307,3,73,36,0,306,305,1,0,0,0,307,308,
1,0,0,0,308,306,1,0,0,0,308,309,1,0,0,0,309,311,1,0,0,0,310,300,1,0,0,0,
310,302,1,0,0,0,310,303,1,0,0,0,310,304,1,0,0,0,311,314,1,0,0,0,312,310,
1,0,0,0,312,313,1,0,0,0,313,70,1,0,0,0,314,312,1,0,0,0,315,317,7,26,0,0,
316,315,1,0,0,0,317,318,1,0,0,0,318,316,1,0,0,0,318,319,1,0,0,0,319,320,
1,0,0,0,320,321,6,35,0,0,321,72,1,0,0,0,322,323,7,27,0,0,323,74,1,0,0,0,
324,326,8,28,0,0,325,324,1,0,0,0,326,327,1,0,0,0,327,325,1,0,0,0,327,328,
1,0,0,0,328,76,1,0,0,0,29,0,90,133,150,209,214,219,225,228,232,237,239,
242,248,252,257,259,261,267,269,277,279,283,289,308,310,312,318,327,1,6,
0,0];
private static __ATN: ATN;
public static get _ATN(): ATN {

View File

@@ -1,25 +1,26 @@
// Generated from FilterQuery.g4 by ANTLR 4.13.1
// Generated from FilterQuery.g4 by ANTLR 4.13.2
import {ParseTreeListener} from "antlr4";
import { QueryContext } from "./FilterQueryParser";
import { ExpressionContext } from "./FilterQueryParser";
import { OrExpressionContext } from "./FilterQueryParser";
import { AndExpressionContext } from "./FilterQueryParser";
import { UnaryExpressionContext } from "./FilterQueryParser";
import { PrimaryContext } from "./FilterQueryParser";
import { ComparisonContext } from "./FilterQueryParser";
import { InClauseContext } from "./FilterQueryParser";
import { NotInClauseContext } from "./FilterQueryParser";
import { ValueListContext } from "./FilterQueryParser";
import { FullTextContext } from "./FilterQueryParser";
import { FunctionCallContext } from "./FilterQueryParser";
import { FunctionParamListContext } from "./FilterQueryParser";
import { FunctionParamContext } from "./FilterQueryParser";
import { ArrayContext } from "./FilterQueryParser";
import { ValueContext } from "./FilterQueryParser";
import { KeyContext } from "./FilterQueryParser";
import { QueryContext } from "./FilterQueryParser.js";
import { ExpressionContext } from "./FilterQueryParser.js";
import { OrExpressionContext } from "./FilterQueryParser.js";
import { AndExpressionContext } from "./FilterQueryParser.js";
import { UnaryExpressionContext } from "./FilterQueryParser.js";
import { PrimaryContext } from "./FilterQueryParser.js";
import { ComparisonContext } from "./FilterQueryParser.js";
import { InClauseContext } from "./FilterQueryParser.js";
import { NotInClauseContext } from "./FilterQueryParser.js";
import { ValueListContext } from "./FilterQueryParser.js";
import { FullTextContext } from "./FilterQueryParser.js";
import { FunctionCallContext } from "./FilterQueryParser.js";
import { SearchCallContext } from "./FilterQueryParser.js";
import { FunctionParamListContext } from "./FilterQueryParser.js";
import { FunctionParamContext } from "./FilterQueryParser.js";
import { ArrayContext } from "./FilterQueryParser.js";
import { ValueContext } from "./FilterQueryParser.js";
import { KeyContext } from "./FilterQueryParser.js";
/**
@@ -147,6 +148,16 @@ export default class FilterQueryListener extends ParseTreeListener {
* @param ctx the parse tree
*/
exitFunctionCall?: (ctx: FunctionCallContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.searchCall`.
* @param ctx the parse tree
*/
enterSearchCall?: (ctx: SearchCallContext) => void;
/**
* Exit a parse tree produced by `FilterQueryParser.searchCall`.
* @param ctx the parse tree
*/
exitSearchCall?: (ctx: SearchCallContext) => void;
/**
* Enter a parse tree produced by `FilterQueryParser.functionParamList`.
* @param ctx the parse tree

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +1,26 @@
// Generated from FilterQuery.g4 by ANTLR 4.13.1
// Generated from FilterQuery.g4 by ANTLR 4.13.2
import {ParseTreeVisitor} from 'antlr4';
import { QueryContext } from "./FilterQueryParser";
import { ExpressionContext } from "./FilterQueryParser";
import { OrExpressionContext } from "./FilterQueryParser";
import { AndExpressionContext } from "./FilterQueryParser";
import { UnaryExpressionContext } from "./FilterQueryParser";
import { PrimaryContext } from "./FilterQueryParser";
import { ComparisonContext } from "./FilterQueryParser";
import { InClauseContext } from "./FilterQueryParser";
import { NotInClauseContext } from "./FilterQueryParser";
import { ValueListContext } from "./FilterQueryParser";
import { FullTextContext } from "./FilterQueryParser";
import { FunctionCallContext } from "./FilterQueryParser";
import { FunctionParamListContext } from "./FilterQueryParser";
import { FunctionParamContext } from "./FilterQueryParser";
import { ArrayContext } from "./FilterQueryParser";
import { ValueContext } from "./FilterQueryParser";
import { KeyContext } from "./FilterQueryParser";
import { QueryContext } from "./FilterQueryParser.js";
import { ExpressionContext } from "./FilterQueryParser.js";
import { OrExpressionContext } from "./FilterQueryParser.js";
import { AndExpressionContext } from "./FilterQueryParser.js";
import { UnaryExpressionContext } from "./FilterQueryParser.js";
import { PrimaryContext } from "./FilterQueryParser.js";
import { ComparisonContext } from "./FilterQueryParser.js";
import { InClauseContext } from "./FilterQueryParser.js";
import { NotInClauseContext } from "./FilterQueryParser.js";
import { ValueListContext } from "./FilterQueryParser.js";
import { FullTextContext } from "./FilterQueryParser.js";
import { FunctionCallContext } from "./FilterQueryParser.js";
import { SearchCallContext } from "./FilterQueryParser.js";
import { FunctionParamListContext } from "./FilterQueryParser.js";
import { FunctionParamContext } from "./FilterQueryParser.js";
import { ArrayContext } from "./FilterQueryParser.js";
import { ValueContext } from "./FilterQueryParser.js";
import { KeyContext } from "./FilterQueryParser.js";
/**
@@ -102,6 +103,12 @@ export default class FilterQueryVisitor<Result> extends ParseTreeVisitor<Result>
* @return the visitor result
*/
visitFunctionCall?: (ctx: FunctionCallContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.searchCall`.
* @param ctx the parse tree
* @return the visitor result
*/
visitSearchCall?: (ctx: SearchCallContext) => Result;
/**
* Visit a parse tree produced by `FilterQueryParser.functionParamList`.
* @param ctx the parse tree

View File

@@ -380,6 +380,19 @@ describe('extractQueryPairs', () => {
consoleSpy.mockRestore();
});
it('does not turn a search() term into a pair', () => {
// The bare form lexes as a KEY; left in it becomes a phantom filter item.
expect(extractQueryPairs("search('err')")).toStrictEqual([]);
expect(extractQueryPairs('search(err)')).toStrictEqual([]);
expect(extractQueryPairs('search(')).toStrictEqual([]);
expect(
extractQueryPairs("search(err) AND service.name = 'api'").map(
(pair) => pair.key,
),
).toStrictEqual(['service.name']);
});
it('should treat lowercase exists as non-value operator', () => {
const input = 'body exists service.name contains "test"';
const result = extractQueryPairs(input);
@@ -821,3 +834,17 @@ describe('getQueryContextAtCursor - partial operator', () => {
expect(ctx.operatorToken).toBe('k');
});
});
describe('getQueryContextAtCursor - function context', () => {
// Each function keyword gets its own lexer token, and every one has to be
// registered as a function token for the editor to offer the function list.
it.each(['has', 'hasAny', 'hasAll', 'hasToken', 'search'])(
'resolves %s to function context',
(functionName) => {
const ctx = getQueryContextAtCursor(functionName, functionName.length);
expect(ctx.isInFunction).toBe(true);
expect(ctx.isInKey).toBe(false);
},
);
});

View File

@@ -1279,6 +1279,41 @@ export function getQueryContextAtCursor(
}
}
// The grammar skips whitespace outright, so hidden-channel tokens do not reach the
// stream today -- but the rest of this file guards against them, so keep the
// assumption in one place rather than spread across callers.
function nextVisibleIndex(tokens: IToken[], start: number): number {
let index = start;
while (index < tokens.length && tokens[index].channel !== 0) {
index += 1;
}
return index;
}
// Returns the token index just past the parenthesised argument list at
// argumentStart, or argumentStart when none follows (a call the user is still
// typing). An unclosed list consumes the remainder.
function indexPastArguments(tokens: IToken[], argumentStart: number): number {
let index = nextVisibleIndex(tokens, argumentStart);
if (index >= tokens.length || tokens[index].type !== FilterQueryLexer.LPAREN) {
return argumentStart;
}
let depth = 0;
for (; index < tokens.length; index++) {
if (tokens[index].type === FilterQueryLexer.LPAREN) {
depth += 1;
} else if (tokens[index].type === FilterQueryLexer.RPAREN) {
depth -= 1;
if (depth === 0) {
return index + 1;
}
}
}
return index;
}
/**
* Extracts all key-operator-value triplets from a query string
* This is useful for getting value suggestions based on the current key and operator
@@ -1324,6 +1359,14 @@ export function extractQueryPairs(query: string): IQueryPair[] {
continue;
}
// A search() term is free text, not a key: the bare form search(x) lexes
// as one, and left in it surfaces as a phantom filter item -- the log
// detail drawer rebuilds its filters from these pairs.
if (token.type === FilterQueryLexer.SEARCH) {
iterator = indexPastArguments(allTokens, iterator);
continue;
}
// If token is a KEY, start a new pair
if (
token.type === FilterQueryLexer.KEY &&

View File

@@ -77,6 +77,7 @@ export function isFunctionToken(tokenType: number): boolean {
FilterQueryLexer.HASANY,
FilterQueryLexer.HASALL,
FilterQueryLexer.HASTOKEN,
FilterQueryLexer.SEARCH,
].includes(tokenType);
}

View File

@@ -4135,6 +4135,10 @@ func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query stri
return readRowsForTimeSeriesResult(rows, vars, columnNames, countOfNumberCols)
}
func isJSONColumn(columnType driver.ColumnType) bool {
return strings.HasPrefix(strings.ToUpper(columnType.DatabaseTypeName()), "JSON")
}
// GetListResultV3 runs the query and returns list of rows
func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([]*v3.Row, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
@@ -4159,6 +4163,12 @@ func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([
for rows.Next() {
var vars = make([]interface{}, len(columnTypes))
for i := range columnTypes {
if isJSONColumn(columnTypes[i]) {
// the driver fails to decode JSON into native Go values, so it is read as raw bytes
var raw []byte
vars[i] = &raw
continue
}
vars[i] = reflect.New(columnTypes[i].ScanType()).Interface()
}
if err := rows.Scan(vars...); err != nil {
@@ -4167,7 +4177,17 @@ func (r *ClickHouseReader) GetListResultV3(ctx context.Context, query string) ([
row := map[string]interface{}{}
var t time.Time
for idx, v := range vars {
if columnNames[idx] == "timestamp" {
if isJSONColumn(columnTypes[idx]) {
raw, ok := v.(*[]byte)
if !ok {
continue
}
var value map[string]interface{}
if err := json.Unmarshal(*raw, &value); err != nil {
return nil, errors.New(err.Error())
}
row[columnNames[idx]] = value
} else if columnNames[idx] == "timestamp" {
switch v := v.(type) {
case *uint64:
t = time.Unix(0, int64(*v))

View File

@@ -3803,6 +3803,10 @@ func (aH *APIHandler) QueryRangeV3(w http.ResponseWriter, r *http.Request) {
return
}
queryRangeParams.UseJSONBody = aH.Signoz.Flagger.BooleanOrEmpty(
r.Context(), flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID),
)
// add temporality for each metric
temporalityErr := aH.PopulateTemporality(r.Context(), orgID, queryRangeParams)
if temporalityErr != nil {

View File

@@ -361,7 +361,7 @@ func generateAggregateClause(panelType v3.PanelType, start, end int64, aggOp v3.
}
}
func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.BuilderQuery, graphLimitQtype string) (string, error) {
func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.BuilderQuery, graphLimitQtype string, useJSONBody bool) (string, error) {
// timerange will be sent in epoch millisecond
logsStart := utils.GetEpochNanoSecs(start)
logsEnd := utils.GetEpochNanoSecs(end)
@@ -405,6 +405,9 @@ func buildLogsQuery(panelType v3.PanelType, start, end, step int64, mq *v3.Build
if mq.AggregateOperator == v3.AggregateOperatorNoOp {
// with noop any filter or different order by other than ts will use new table
sqlSelect := constants.LogsSQLSelectV2
if useJSONBody {
sqlSelect = constants.LogsSQLSelectV2WithBodyJSON
}
queryTmpl := sqlSelect + "from signoz_logs.%s where %s%s order by %s"
query := fmt.Sprintf(queryTmpl, DISTRIBUTED_LOGS_V2, timeFilter, filterSubQuery, orderBy)
return query, nil
@@ -517,7 +520,7 @@ func PrepareLogsQuery(start, end int64, queryType v3.QueryType, panelType v3.Pan
return query, nil
} else if options.GraphLimitQtype == constants.FirstQueryGraphLimit {
// give me just the group_by names (no values)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype, options.UseJSONBody)
if err != nil {
return "", err
}
@@ -525,14 +528,14 @@ func PrepareLogsQuery(start, end int64, queryType v3.QueryType, panelType v3.Pan
return query, nil
} else if options.GraphLimitQtype == constants.SecondQueryGraphLimit {
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype, options.UseJSONBody)
if err != nil {
return "", err
}
return query, nil
}
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype)
query, err := buildLogsQuery(panelType, start, end, mq.StepInterval, mq, options.GraphLimitQtype, options.UseJSONBody)
if err != nil {
return "", err
}

View File

@@ -899,7 +899,7 @@ func Test_buildLogsQuery(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildLogsQuery(tt.args.panelType, tt.args.start, tt.args.end, tt.args.step, tt.args.mq, tt.args.graphLimitQtype)
got, err := buildLogsQuery(tt.args.panelType, tt.args.start, tt.args.end, tt.args.step, tt.args.mq, tt.args.graphLimitQtype, false)
if (err != nil) != tt.wantErr {
t.Errorf("buildLogsQuery() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -215,18 +215,18 @@ func (qb *QueryBuilder) PrepareQueries(params *v3.QueryRangeParamsV3) (map[strin
case v3.DataSourceLogs:
// for ts query with limit replace it as it is already formed
if compositeQuery.PanelType == v3.PanelTypeGraph && query.Limit > 0 && len(query.GroupBy) > 0 {
limitQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.FirstQueryGraphLimit})
limitQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.FirstQueryGraphLimit, UseJSONBody: params.UseJSONBody})
if err != nil {
return nil, err
}
placeholderQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.SecondQueryGraphLimit})
placeholderQuery, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: constants.SecondQueryGraphLimit, UseJSONBody: params.UseJSONBody})
if err != nil {
return nil, err
}
query := fmt.Sprintf(placeholderQuery, limitQuery)
queries[queryName] = query
} else {
queryString, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: ""})
queryString, err := qb.options.BuildLogQuery(start, end, compositeQuery.QueryType, compositeQuery.PanelType, query, v3.QBOptions{GraphLimitQtype: "", UseJSONBody: params.UseJSONBody})
if err != nil {
return nil, err
}

View File

@@ -196,13 +196,17 @@ const (
"CAST((attributes_bool_key, attributes_bool_value), 'Map(String, Bool)') as attributes_bool," +
"CAST((resources_string_key, resources_string_value), 'Map(String, String)') as resources_string," +
"CAST((scope_string_key, scope_string_value), 'Map(String, String)') as scope "
LogsSQLSelectV2 = "SELECT " +
"timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, " +
"attributes_string, " +
logsSQLSelectV2Head = "SELECT " +
"timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, "
logsSQLSelectV2Tail = "attributes_string, " +
"attributes_number, " +
"attributes_bool, " +
"resources_string, " +
"scope_string "
LogsSQLSelectV2 = logsSQLSelectV2Head + "body, " + logsSQLSelectV2Tail
// Orgs on JSON bodies keep the body in body_v2 and have the body column written empty.
// Selected as JSON so the response carries the same body object v5 returns.
LogsSQLSelectV2WithBodyJSON = logsSQLSelectV2Head + "body_v2 as body, " + logsSQLSelectV2Tail
TracesExplorerViewSQLSelectWithSubQuery = "(SELECT traceID, durationNano, " +
"serviceName, name FROM %s.%s WHERE parentSpanID = '' AND %s ORDER BY durationNano DESC LIMIT 1 BY traceID"
TracesExplorerViewSQLSelectBeforeSubQuery = "SELECT subQuery.serviceName as `subQuery.serviceName`, subQuery.name as `subQuery.name`, count() AS " +

View File

@@ -435,6 +435,8 @@ type QueryRangeParamsV3 struct {
NoCache bool `json:"noCache"`
Version string `json:"-"`
FormatForWeb bool `json:"formatForWeb,omitempty"`
// Resolved from the use_json_body feature flag by the handler, never sent by clients.
UseJSONBody bool `json:"-"`
}
func (q *QueryRangeParamsV3) Clone() *QueryRangeParamsV3 {
@@ -450,6 +452,7 @@ func (q *QueryRangeParamsV3) Clone() *QueryRangeParamsV3 {
NoCache: q.NoCache,
Version: q.Version,
FormatForWeb: q.FormatForWeb,
UseJSONBody: q.UseJSONBody,
}
}
@@ -1469,4 +1472,5 @@ type MetricMetadataResponse struct {
type QBOptions struct {
GraphLimitQtype string
IsLivetailQuery bool
UseJSONBody bool
}

View File

@@ -6,6 +6,6 @@ echo "Generating TypeScript parser..."
mkdir -p frontend/src/parser
# Generate TypeScript parser
antlr4 -Dlanguage=TypeScript -o frontend/src/parser grammar/FilterQuery.g4 -visitor
(cd grammar && antlr4 -Dlanguage=TypeScript -o ../frontend/src/parser FilterQuery.g4 -visitor)
echo "TypeScript parser generation complete"

View File

@@ -16,6 +16,7 @@ from wiremock.resources.mappings import (
from fixtures import reuse, types
from fixtures.logger import setup_logger
from fixtures.role import find_role_by_name
logger = setup_logger(__name__)
@@ -32,6 +33,7 @@ USER_VIEWER_EMAIL = "viewer@integration.test"
USER_VIEWER_PASSWORD = "password123Z$"
USERS_BASE = "/api/v2/users"
USER_ROLES_BASE = "/api/v2/user_roles"
def _login(signoz: types.SigNoz, email: str, password: str) -> str:
@@ -344,24 +346,38 @@ def create_active_user(
password: str,
name: str = "",
) -> str:
"""Invite a user and activate via resetPassword. Returns user ID."""
"""Create a pending invite user and activate it by setting a password.
role is a managed role name, e.g. signoz-viewer. Returns the user ID.
"""
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": role, "name": name},
signoz.self.host_configs["8080"].get(USERS_BASE),
json={
"email": email,
"displayName": name,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, role)}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": password, "token": invited_user["token"]},
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": password, "token": response.json()["data"]["token"]},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
return invited_user["id"]
return user_id
def find_user_by_email(signoz: types.SigNoz, token: str, email: str) -> dict:
@@ -409,21 +425,21 @@ def change_user_role(
Role names should be managed role names (e.g. signoz-editor).
"""
# Get current roles to find the old role's ID
# Get current roles to find the old role's user_role entry ID
response = requests.get(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/roles"),
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
roles = response.json()["data"]
user_roles = response.json()["data"]["userRoles"]
old_role_entry = next((r for r in roles if r["name"] == old_role), None)
old_role_entry = next((ur for ur in user_roles if ur["role"]["name"] == old_role), None)
assert old_role_entry is not None, f"User does not have role '{old_role}'"
# Remove old role
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/roles/{old_role_entry['id']}"),
signoz.self.host_configs["8080"].get(f"{USER_ROLES_BASE}/{old_role_entry['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
@@ -431,9 +447,9 @@ def change_user_role(
# Assign new role
response = requests.post(
signoz.self.host_configs["8080"].get(f"{USERS_BASE}/{user_id}/roles"),
json={"name": new_role},
signoz.self.host_configs["8080"].get(USER_ROLES_BASE),
json={"userId": user_id, "roleId": find_role_by_name(signoz, admin_token, new_role)},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.status_code == HTTPStatus.CREATED, response.text

12
tests/fixtures/idp.py vendored
View File

@@ -634,18 +634,6 @@ def get_oidc_domain(signoz: types.SigNoz, admin_token: str) -> dict:
)
def get_user_by_email(signoz: types.SigNoz, admin_token: str, email: str) -> dict:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
return next(
(user for user in response.json()["data"] if user["email"] == email),
None,
)
def perform_oidc_login(
signoz: types.SigNoz, # pylint: disable=unused-argument
idp: types.TestContainerIDP,

View File

@@ -9,18 +9,14 @@ from fixtures import types
from fixtures.fs import get_testdata_file_path
@pytest.fixture(name="find_role_id", scope="function")
def find_role_id(signoz: types.SigNoz) -> Callable[[str, str], str]:
def _find(token: str, name: str) -> str:
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
return next(r["id"] for r in resp.json()["data"] if r["name"] == name)
return _find
def find_role_by_name(signoz: types.SigNoz, token: str, name: str) -> str:
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
return next(r["id"] for r in resp.json()["data"] if r["name"] == name)
@pytest.fixture(name="create_role", scope="function")

View File

@@ -4,22 +4,13 @@ import requests
from fixtures import types
from fixtures.logger import setup_logger
from fixtures.role import find_role_by_name
logger = setup_logger(__name__)
SERVICE_ACCOUNT_BASE = "/api/v1/service_accounts"
def find_role_by_name(signoz: types.SigNoz, token: str, name: str) -> str:
resp = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
return next(r["id"] for r in resp.json()["data"] if r["name"] == name)
def create_service_account(signoz: types.SigNoz, token: str, name: str, role: str = "signoz-viewer") -> str:
"""Create a service account, assign a role, and return its ID."""
resp = requests.post(

View File

@@ -13,12 +13,14 @@ from fixtures.auth import (
USER_ADMIN_PASSWORD,
add_license,
assert_user_has_role,
create_active_user,
find_user_with_roles_by_email,
)
from fixtures.idp import (
get_saml_domain,
perform_saml_login,
)
from fixtures.role import find_role_by_name
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
@@ -509,8 +511,12 @@ def test_saml_sso_login_activates_pending_invite_user(
# Invite user as ADMIN
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": "ADMIN", "name": "SAML SSO Pending User"},
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": email,
"displayName": "SAML SSO Pending User",
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-admin")}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
@@ -547,22 +553,14 @@ def test_saml_sso_deleted_user_gets_new_user_on_login(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# --- Step 1: Invite and activate via password reset ---
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": "EDITOR", "name": "SAML SSO Lifecycle User"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
user_id = create_active_user(
signoz,
admin_token,
email=email,
role="signoz-editor",
password="password123Z$",
name="SAML SSO Lifecycle User",
)
assert response.status_code == HTTPStatus.CREATED
user_id = response.json()["data"]["id"]
reset_token = response.json()["data"]["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# --- Step 2: Soft delete via DB using API
response = requests.delete(

View File

@@ -17,6 +17,7 @@ from fixtures.idp import (
get_oidc_domain,
perform_oidc_login,
)
from fixtures.role import find_role_by_name
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
@@ -464,8 +465,12 @@ def test_oidc_sso_login_activates_pending_invite_user(
# Invite user as ADMIN
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": email, "role": "ADMIN", "name": "OIDC SSO Pending User"},
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": email,
"displayName": "OIDC SSO Pending User",
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-admin")}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)

View File

@@ -12,7 +12,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import transaction_group
from fixtures.role import find_role_by_name, transaction_group
from fixtures.types import Operation, SigNoz, TestContainerDocker
V2_BASE_URL = "/api/v2/dashboards"
@@ -64,8 +64,8 @@ def test_setup_managed_role_users(
existing_emails = {user["email"] for user in response.json()["data"]}
for email, role, password, name in (
(_EDITOR_EMAIL, "EDITOR", _EDITOR_PASSWORD, "dashboard authz editor"),
(_VIEWER_EMAIL, "VIEWER", _VIEWER_PASSWORD, "dashboard authz viewer"),
(_EDITOR_EMAIL, "signoz-editor", _EDITOR_PASSWORD, "dashboard authz editor"),
(_VIEWER_EMAIL, "signoz-viewer", _VIEWER_PASSWORD, "dashboard authz viewer"),
):
if email not in existing_emails:
create_active_user(signoz, admin_token, email=email, role=role, password=password, name=name)
@@ -411,7 +411,7 @@ def test_setup_scoped_actor(
],
)
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="VIEWER", password=_ACTOR_PASSWORD, name="dashboard fga actor")
user_id = create_active_user(signoz, admin_token, email=_ACTOR_EMAIL, role="signoz-viewer", password=_ACTOR_PASSWORD, name="dashboard fga actor")
change_user_role(signoz, admin_token, user_id, "signoz-viewer", _ACTOR_ROLE_NAME)
@@ -469,10 +469,9 @@ def test_publish_and_unpublish_require_update_on_the_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_role_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
actor_role_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{V2_BASE_URL}?limit={MAX_LIST_LIMIT}"),
@@ -579,7 +578,6 @@ def test_dashboard_authz_cleanup(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor = find_user_by_email(signoz, admin_token, _ACTOR_EMAIL)
@@ -600,7 +598,7 @@ def test_dashboard_authz_cleanup(
assert response.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {response.text}"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_id(admin_token, _ACTOR_ROLE_NAME)}"),
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)

View File

@@ -10,7 +10,7 @@ from wiremock.client import (
)
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license, create_active_user
from fixtures.gateway import (
TEST_KEY_ID,
common_gateway_headers,
@@ -43,21 +43,13 @@ def test_create_editor_user(
"""Invite and register an editor user for gateway API tests."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
invite_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": GATEWAY_APIS_EDITOR_EMAIL, "role": "EDITOR"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
create_active_user(
signoz,
admin_token,
email=GATEWAY_APIS_EDITOR_EMAIL,
role="signoz-editor",
password=GATEWAY_APIS_EDITOR_PASSWORD,
)
assert invite_response.status_code == HTTPStatus.CREATED
reset_token = invite_response.json()["data"]["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": GATEWAY_APIS_EDITOR_PASSWORD, "token": reset_token},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# ---------------------------------------------------------------------------

View File

@@ -15,6 +15,7 @@ from fixtures.auth import (
find_user_with_roles_by_email,
)
from fixtures.logger import setup_logger
from fixtures.role import find_role_by_name
logger = setup_logger(__name__)
@@ -82,31 +83,36 @@ def test_register(signoz: types.SigNoz, get_token: Callable[[str, str], str]) ->
def test_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Generate an invite token for the editor user
# Create the editor user as a pending invite
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": USER_EDITOR_EMAIL, "role": "EDITOR", "name": USER_EDITOR_NAME},
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": USER_EDITOR_EMAIL,
"displayName": USER_EDITOR_NAME,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
assert invited_user["email"] == USER_EDITOR_EMAIL
assert invited_user["role"] == "EDITOR"
user_id = response.json()["data"]["id"]
# Verify the user appears in the users list but as pending_invite status
found_user = find_user_with_roles_by_email(signoz, admin_token, USER_EDITOR_EMAIL)
assert found_user["status"] == "pending_invite"
assert_user_has_role(found_user, "signoz-editor")
reset_token = invited_user["token"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
# Reset the password to complete the invite flow (activates the user and also grants authz)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": USER_EDITOR_PASSWORD, "token": reset_token},
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": USER_EDITOR_PASSWORD, "token": response.json()["data"]["token"]},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
@@ -130,18 +136,28 @@ def test_revoke_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str
# Invite the viewer user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": USER_VIEWER_EMAIL, "role": "VIEWER"},
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": USER_VIEWER_EMAIL,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-viewer")}],
},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
user_id = response.json()["data"]["id"]
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
reset_token = response.json()["data"]["token"]
# Delete the pending invite user (revoke the invite)
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{invited_user['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
)
@@ -149,7 +165,7 @@ def test_revoke_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str
# Try to use the reset token — should fail (user deleted)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$", "token": reset_token},
timeout=2,
)
@@ -157,67 +173,83 @@ def test_revoke_invite(signoz: types.SigNoz, get_token: Callable[[str, str], str
def test_provision_user(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
"""
Simulates the upstream zeus provisioning flow:
1. Invite a user as ADMIN (register already happened via test_register)
2. List users to find the invited user's ID
3. Get reset password token for that user
4. Use the token to set the password and activate the user
5. Verify the user can log in
"""
"""Mirrors the zeus provisioning flow."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
provisioned_email = "zeus-provisioned@integration.test"
provisioned_name = "zeus provisioned user"
provisioned_password = "password123Z$"
# Step 1: Invite user as ADMIN (mirrors zeus inviteUserOnSigNoz)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
role_id = next(role["id"] for role in response.json()["data"] if role["name"] == "signoz-admin")
create_payload = {
"email": provisioned_email,
"displayName": provisioned_name,
"userRoles": [{"id": role_id}],
}
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": provisioned_email,
"name": provisioned_name,
"role": "ADMIN",
},
signoz.self.host_configs["8080"].get("/api/v2/users"),
json=create_payload,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
user_id = response.json()["data"]["id"]
# Step 2: List users to find the invited user's ID (mirrors zeus GET /api/v1/user)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/user"),
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/users"),
json=create_payload,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
users = response.json()["data"]
found_user = next((u for u in users if u["email"] == provisioned_email), None)
assert found_user is not None
user_id = found_user["id"]
assert response.status_code == HTTPStatus.CONFLICT, response.text
# Step 3: Get reset password token (mirrors zeus GET /api/v1/getResetPasswordToken/{id})
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/getResetPasswordToken/{user_id}"),
signoz.self.host_configs["8080"].get("/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert response.status_code == HTTPStatus.OK, response.text
existing_id = next(user["id"] for user in response.json()["data"] if user["email"] == provisioned_email.strip().lower())
assert existing_id == user_id
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
reset_token = response.json()["data"]["token"]
assert reset_token is not None
assert reset_token != ""
# Step 4: Use the token to set password and activate user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": provisioned_password, "token": reset_token},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
# Step 5: Verify the provisioned user can log in and is active with admin role
user_token = get_token(provisioned_email, provisioned_password)
assert user_token is not None
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={"email": provisioned_email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
org_id = response.json()["data"]["orgs"][0]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={"email": provisioned_email, "password": provisioned_password, "orgId": org_id},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["accessToken"] != ""
provisioned_user = find_user_with_roles_by_email(signoz, admin_token, provisioned_email)
assert provisioned_user["status"] == "active"

View File

@@ -5,7 +5,7 @@ import requests
from sqlalchemy import sql
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, find_user_by_email
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, create_active_user, find_user_by_email
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
@@ -17,24 +17,13 @@ PASSWORD_USER_PASSWORD = "password123Z$"
def test_change_password(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create another admin user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": PASSWORD_USER_EMAIL, "role": "ADMIN"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
create_active_user(
signoz,
admin_token,
email=PASSWORD_USER_EMAIL,
role="signoz-admin",
password=PASSWORD_USER_PASSWORD,
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Reset password to activate user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": PASSWORD_USER_PASSWORD, "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try logging in with the password
token = get_token(PASSWORD_USER_EMAIL, PASSWORD_USER_PASSWORD)
@@ -110,7 +99,7 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
# Reset the password with a bad password which should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password", "token": token},
timeout=2,
)
@@ -119,51 +108,19 @@ def test_reset_password(signoz: types.SigNoz, get_token: Callable[[str, str], st
# Reset the password with a good password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$NEWNEW#!", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
token = get_token(PASSWORD_USER_EMAIL, "password123Z$NEWNEW#!")
assert token is not None
def test_reset_password_v2(signoz: types.SigNoz, get_token: Callable[[str, str], str]) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
found_user = find_user_by_email(signoz, admin_token, PASSWORD_USER_EMAIL)
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{found_user['id']}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
token = response.json()["data"]["token"]
# A password failing the strength policy is rejected without consuming the token
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "resetV2Password123Z$", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
assert get_token(PASSWORD_USER_EMAIL, "resetV2Password123Z$") is not None
assert get_token(PASSWORD_USER_EMAIL, "password123Z$NEWNEW#!") is not None
# The token is single use, so replaying it no longer resolves
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "resetV2Password456Z$", "token": token},
json={"password": "password123Z$REPLAY#!", "token": token},
timeout=2,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
@@ -204,7 +161,7 @@ def test_reset_password_with_no_password(signoz: types.SigNoz, get_token: Callab
# Reset the password with a good password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "FINALPASSword123!#[", "token": token},
timeout=2,
)
@@ -262,28 +219,14 @@ def test_forgot_password_creates_reset_token(signoz: types.SigNoz, get_token: Ca
forgot_email = "forgot@integration.test"
# Create a user specifically for testing forgot password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": forgot_email,
"role": "EDITOR",
"name": "forgotpassword user",
},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
create_active_user(
signoz,
admin_token,
email=forgot_email,
role="signoz-editor",
password="originalPassword123Z$",
name="forgotpassword user",
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Activate user via reset password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "originalPassword123Z$", "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Get org ID
response = requests.get(
@@ -326,7 +269,7 @@ def test_forgot_password_creates_reset_token(signoz: types.SigNoz, get_token: Ca
# Reset password with a valid strong password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "newSecurePassword123Z$!", "token": reset_token},
timeout=2,
)
@@ -416,7 +359,7 @@ def test_reset_password_with_expired_token(signoz: types.SigNoz, get_token: Call
# Try to use the expired token - should fail with 401 Unauthorized
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "expiredTokenPassword123Z$!", "token": reset_token},
timeout=2,
)

View File

@@ -12,6 +12,7 @@ from fixtures.auth import (
change_user_role,
create_active_user,
)
from fixtures.role import find_role_by_name
ROLECHANGE_USER_EMAIL = "admin+rolechange@integration.test"
ROLECHANGE_USER_PASSWORD = "password123Z$"
@@ -23,27 +24,14 @@ def test_change_role(
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Create a new user as VIEWER
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": ROLECHANGE_USER_EMAIL, "role": "VIEWER"},
timeout=2,
headers={"Authorization": f"Bearer {admin_token}"},
create_active_user(
signoz,
admin_token,
email=ROLECHANGE_USER_EMAIL,
role="signoz-viewer",
password=ROLECHANGE_USER_PASSWORD,
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# Activate user via reset password
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": ROLECHANGE_USER_PASSWORD, "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Make some API calls as new user
new_user_token = get_token(ROLECHANGE_USER_EMAIL, ROLECHANGE_USER_PASSWORD)
@@ -133,7 +121,7 @@ def test_assign_role_is_additive(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify POST /api/v2/users/{id}/roles ADDS a role alongside existing ones and is idempotent."""
"""Verify POST /api/v2/user_roles ADDS a role alongside existing ones and is idempotent."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -144,15 +132,17 @@ def test_assign_role_is_additive(
me = response.json()["data"]
user_id = me["id"]
editor_role_id = find_role_by_name(signoz, admin_token, "signoz-editor")
# User currently has signoz-admin from test_change_role.
# Assign signoz-editor — should be additive, admin stays.
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
json={"name": "signoz-editor"},
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": user_id, "roleId": editor_role_id},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert response.status_code == HTTPStatus.CREATED
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
@@ -168,12 +158,12 @@ def test_assign_role_is_additive(
# Idempotency: assigning the same role again succeeds without duplicates
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
json={"name": "signoz-editor"},
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": user_id, "roleId": editor_role_id},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert response.status_code == HTTPStatus.CREATED
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
@@ -225,7 +215,7 @@ def test_remove_role(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify DELETE /api/v2/users/{id}/roles/{roleId} removes only the specified role."""
"""Verify DELETE /api/v2/user_roles/{id} removes only the specified role."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -236,18 +226,11 @@ def test_remove_role(
me = response.json()["data"]
user_id = me["id"]
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
roles = response.json()["data"]
editor_role_id = next((r for r in roles if r["name"] == "signoz-editor"), None)["id"]
assert editor_role_id is not None
editor_entry_id = next((ur["id"] for ur in me["userRoles"] if ur["role"]["name"] == "signoz-editor"), None)
assert editor_entry_id is not None
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles/{editor_role_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{editor_entry_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
@@ -296,7 +279,7 @@ def test_admin_cannot_assign_role_to_self(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify POST /api/v2/users/{own_id}/roles is rejected (self-mutation guard)."""
"""Verify POST /api/v2/user_roles for the caller's own user is rejected (self-mutation guard)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -307,8 +290,8 @@ def test_admin_cannot_assign_role_to_self(
admin_data = response.json()["data"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_data['id']}/roles"),
json={"name": "signoz-editor"},
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": admin_data["id"], "roleId": find_role_by_name(signoz, admin_token, "signoz-editor")},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
@@ -319,7 +302,7 @@ def test_admin_cannot_remove_own_role(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
):
"""Verify DELETE /api/v2/users/{own_id}/roles/{roleId} is rejected (self-mutation guard)."""
"""Verify DELETE /api/v2/user_roles/{id} for the caller's own assignment is rejected (self-mutation guard)."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/users/me"),
@@ -329,18 +312,11 @@ def test_admin_cannot_remove_own_role(
assert response.status_code == HTTPStatus.OK
admin_data = response.json()["data"]
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_data['id']}/roles"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
roles = response.json()["data"]
admin_role_id = next((r for r in roles if r["name"] == "signoz-admin"), None)["id"]
assert admin_role_id is not None
admin_entry_id = next((ur["id"] for ur in admin_data["userRoles"] if ur["role"]["name"] == "signoz-admin"), None)
assert admin_entry_id is not None
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{admin_data['id']}/roles/{admin_role_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{admin_entry_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
@@ -359,7 +335,7 @@ def test_editor_cannot_manage_roles(
signoz,
admin_token,
email="viewer+roleauth@integration.test",
role="VIEWER",
role="signoz-viewer",
password=ROLECHANGE_USER_PASSWORD,
name="viewer roleauth",
)
@@ -376,8 +352,8 @@ def test_editor_cannot_manage_roles(
# POST assign role — forbidden
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles"),
json={"name": "signoz-editor"},
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": viewer_id, "roleId": find_role_by_name(signoz, admin_token, "signoz-editor")},
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)
@@ -385,16 +361,15 @@ def test_editor_cannot_manage_roles(
# DELETE remove role — forbidden
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
viewer_roles = response.json()["data"]
viewer_role_id = next((r for r in viewer_roles if r["name"] == "signoz-viewer"), None)["id"]
viewer_entry_id = next(ur["id"] for ur in response.json()["data"]["userRoles"] if ur["role"]["name"] == "signoz-viewer")
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{viewer_id}/roles/{viewer_role_id}"),
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{viewer_entry_id}"),
headers={"Authorization": f"Bearer {editor_token}"},
timeout=5,
)

View File

@@ -4,6 +4,7 @@ from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.role import find_role_by_name
from fixtures.types import SigNoz
DUPLICATE_USER_EMAIL = "duplicate@integration.test"
@@ -20,38 +21,49 @@ def test_duplicate_user_invite_rejected(
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
# Invite a new user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "EDITOR"},
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": DUPLICATE_USER_EMAIL,
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
user_id = response.json()["data"]["id"]
# Invite the same email again — should fail
# Invite the same email again while still pending — should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={"email": DUPLICATE_USER_EMAIL, "userRoles": [{"id": viewer_role_id}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CONFLICT
# activate the user
response = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/reset_password_tokens"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": "password123Z$", "token": reset_token},
signoz.self.host_configs["8080"].get("/api/v2/factor_password/reset"),
json={"password": "password123Z$", "token": response.json()["data"]["token"]},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Try to invite the same email again — should fail
# Try to invite the same email again once active — should fail
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={"email": DUPLICATE_USER_EMAIL, "userRoles": [{"id": viewer_role_id}]},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)

View File

@@ -3,7 +3,12 @@ from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, create_active_user
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
assert_user_has_role,
create_active_user,
)
from fixtures.types import SigNoz
@@ -22,71 +27,45 @@ def test_reinvite_deleted_user(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
reinvite_user_email = "reinvite@integration.test"
reinvite_user_name = "reinvite user"
reinvite_user_role = "EDITOR"
reinvite_user_password = "password123Z$"
# invite the user
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": reinvite_user_email,
"role": reinvite_user_role,
"name": reinvite_user_name,
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
user_id = create_active_user(
signoz,
admin_token,
email=reinvite_user_email,
role="signoz-editor",
password="password123Z$",
name="reinvite user",
)
assert response.status_code == HTTPStatus.CREATED, response.text
invited_user = response.json()["data"]
reset_token = invited_user["token"]
# reset the password to make it active
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={"password": reinvite_user_password, "token": reset_token},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# call the delete api which now soft deletes the user
response = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{invited_user['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
# Re-invite the same email — should succeed
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={
"email": reinvite_user_email,
"role": "VIEWER",
"name": "reinvite user v2",
},
# Re-invite the same email — should succeed and create a different user
reinvited_user_id = create_active_user(
signoz,
admin_token,
email=reinvite_user_email,
role="signoz-viewer",
password="newPassword123Z$",
name="reinvite user v2",
)
assert reinvited_user_id != user_id
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{reinvited_user_id}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED, response.text
reinvited_user = response.json()["data"]
assert reinvited_user["role"] == "VIEWER"
assert reinvited_user["id"] != invited_user["id"] # confirms a new user was created
reinvited_user_reset_password_token = reinvited_user["token"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/resetPassword"),
json={
"password": "newPassword123Z$",
"token": reinvited_user_reset_password_token,
},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT
assert response.status_code == HTTPStatus.OK, response.text
assert_user_has_role(response.json()["data"], "signoz-viewer")
# Verify user can log in with new password
user_token = get_token("reinvite@integration.test", "newPassword123Z$")
user_token = get_token(reinvite_user_email, "newPassword123Z$")
assert user_token is not None
@@ -105,7 +84,7 @@ def test_delete_user(
signoz,
admin_token,
email="delete-verify-v2@integration.test",
role="EDITOR",
role="signoz-editor",
password="password123Z$",
name="delete verify v2",
)

View File

@@ -8,6 +8,7 @@ from sqlalchemy import sql
from sqlalchemy.exc import IntegrityError
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.role import find_role_by_name
from fixtures.types import SigNoz
UNIQUE_INDEX_USER_EMAIL = "useruniqueindex@integration.test"
@@ -41,11 +42,11 @@ def test_unique_index_allows_multiple_deleted_rows(
# Step 1: invite and delete the first user
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": UNIQUE_INDEX_USER_EMAIL,
"role": "EDITOR",
"name": "unique index user v1",
"displayName": "unique index user v1",
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
@@ -62,11 +63,11 @@ def test_unique_index_allows_multiple_deleted_rows(
# Step 2: re-invite and delete the same email (second deleted row)
resp = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
signoz.self.host_configs["8080"].get("/api/v2/users"),
json={
"email": UNIQUE_INDEX_USER_EMAIL,
"role": "EDITOR",
"name": "unique index user v2",
"displayName": "unique index user v2",
"userRoles": [{"id": find_role_by_name(signoz, admin_token, "signoz-editor")}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,

View File

@@ -41,7 +41,7 @@ def test_setup(
transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"]),
],
)
user_id = create_active_user(signoz, admin_token, email=scoped_email, role="VIEWER", password=user_password)
user_id = create_active_user(signoz, admin_token, email=scoped_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", scoped_role)

View File

@@ -24,11 +24,11 @@ def test_setup(
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, any_key_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/*"])])
any_user = create_active_user(signoz, admin_token, email=any_key_email, role="VIEWER", password=user_password)
any_user = create_active_user(signoz, admin_token, email=any_key_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, any_user, "signoz-viewer", any_key_role)
create_role(admin_token, builder_all_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/*"])])
all_user = create_active_user(signoz, admin_token, email=builder_all_email, role="VIEWER", password=user_password)
all_user = create_active_user(signoz, admin_token, email=builder_all_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, all_user, "signoz-viewer", builder_all_role)

View File

@@ -38,15 +38,15 @@ def test_setup(
transaction_group("read", "telemetryresource", "meter-metrics", ["clickhouse_sql/*"]),
],
)
chsql_user = create_active_user(signoz, admin_token, email=chsql_email, role="VIEWER", password=user_password)
chsql_user = create_active_user(signoz, admin_token, email=chsql_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, chsql_user, "signoz-viewer", chsql_role)
create_role(admin_token, key_a_role, [transaction_group("read", "telemetryresource", "traces", ["builder_query/signoz.workspace.key.id/key-a"])])
key_a_user = create_active_user(signoz, admin_token, email=key_a_email, role="VIEWER", password=user_password)
key_a_user = create_active_user(signoz, admin_token, email=key_a_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, key_a_user, "signoz-viewer", key_a_role)
# A plain managed viewer (signoz-viewer) — for the meter-metrics/audit-logs policy checks.
create_active_user(signoz, admin_token, email=viewer_email, role="VIEWER", password=user_password)
create_active_user(signoz, admin_token, email=viewer_email, role="signoz-viewer", password=user_password)
def test_clickhouse_sql_requires_chsql_grant(

View File

@@ -24,7 +24,7 @@ def test_setup(
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, spacey_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/key with space"])])
user_id = create_active_user(signoz, admin_token, email=spacey_email, role="VIEWER", password=user_password)
user_id = create_active_user(signoz, admin_token, email=spacey_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", spacey_role)

View File

@@ -21,7 +21,7 @@ def test_setup(
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, keywild_role, [transaction_group("read", "telemetryresource", "logs", ["builder_query/signoz.workspace.key.id/*"])])
user_id = create_active_user(signoz, admin_token, email=keywild_email, role="VIEWER", password=user_password)
user_id = create_active_user(signoz, admin_token, email=keywild_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, user_id, "signoz-viewer", keywild_role)

View File

@@ -9,6 +9,7 @@ from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.role import (
expected_managed_transaction_keys,
find_role_by_name,
flatten_transaction_groups,
managed_role_names,
)
@@ -67,11 +68,10 @@ def test_managed_role_transactions_match_expected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
role_name: str,
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, role_name)
role_id = find_role_by_name(signoz, admin_token, role_name)
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"),

View File

@@ -12,7 +12,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import flatten_transaction_groups, transaction_group
from fixtures.role import find_role_by_name, flatten_transaction_groups, transaction_group
CRUD_ROLE_NAME = "crud-test-role"
CRUD_ASSIGNEE_ROLE_NAME = "crud-assignee-role"
@@ -61,10 +61,9 @@ def test_declarative_update_adds_and_removes_transactions(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, CRUD_ROLE_NAME)
role_id = find_role_by_name(signoz, admin_token, CRUD_ROLE_NAME)
def put_transactions(groups: list[dict]) -> None:
resp = requests.put(
@@ -208,10 +207,9 @@ def test_managed_role_is_immutable(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
admin_role_id = find_role_id(admin_token, "signoz-admin")
admin_role_id = find_role_by_name(signoz, admin_token, "signoz-admin")
resp = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{admin_role_id}"),
@@ -239,27 +237,27 @@ def test_delete_role_with_assignee_guarded(
signoz,
admin_token,
email=CRUD_ASSIGNEE_USER_EMAIL,
role="VIEWER",
role="signoz-viewer",
password=CRUD_ASSIGNEE_USER_PASSWORD,
name="crud-assignee-user",
)
resp = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"),
json={"name": CRUD_ASSIGNEE_ROLE_NAME},
signoz.self.host_configs["8080"].get("/api/v2/user_roles"),
json={"userId": user_id, "roleId": role_id},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert resp.status_code == HTTPStatus.OK, resp.text
assert resp.status_code == HTTPStatus.CREATED, resp.text
resp = requests.delete(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{role_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.BAD_REQUEST, f"delete role with assignee: expected 400, got {resp.status_code}: {resp.text}"
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, resp.text
entry = next(r for r in resp.json()["data"] if r["name"] == CRUD_ASSIGNEE_ROLE_NAME)
entry = next(ur for ur in resp.json()["data"]["userRoles"] if ur["role"]["name"] == CRUD_ASSIGNEE_ROLE_NAME)
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v2/users/{user_id}/roles/{entry['id']}"),
signoz.self.host_configs["8080"].get(f"/api/v2/user_roles/{entry['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)

View File

@@ -13,7 +13,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import transaction_group
from fixtures.role import find_role_by_name, transaction_group
_ACTOR_ROLE_NAME = "role-fga-actor"
_ACTOR_USER_EMAIL = "customrole+rolefga@integration.test"
@@ -57,7 +57,7 @@ def test_setup_actor_and_targets(
signoz,
admin_token,
email=_ACTOR_USER_EMAIL,
role="VIEWER",
role="signoz-viewer",
password=_ACTOR_USER_PASSWORD,
name="role-fga-test-user",
)
@@ -68,12 +68,11 @@ def test_read_scoped_to_granted_role(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_ACTOR_USER_EMAIL, _ACTOR_USER_PASSWORD)
a_id = find_role_id(admin_token, _TARGET_A)
b_id = find_role_id(admin_token, _TARGET_B)
a_id = find_role_by_name(signoz, admin_token, _TARGET_A)
b_id = find_role_by_name(signoz, admin_token, _TARGET_B)
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v1/roles/{a_id}"), headers={"Authorization": f"Bearer {token}"}, timeout=5)
assert resp.status_code == HTTPStatus.OK, f"read granted role: {resp.text}"
@@ -101,11 +100,10 @@ def test_create_is_collection_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
create_role: Callable[..., str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
token = get_token(_ACTOR_USER_EMAIL, _ACTOR_USER_PASSWORD)
resp = requests.post(
@@ -138,12 +136,11 @@ def test_update_scoped_to_granted_role(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
a_id = find_role_id(admin_token, _TARGET_A)
b_id = find_role_id(admin_token, _TARGET_B)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
a_id = find_role_by_name(signoz, admin_token, _TARGET_A)
b_id = find_role_by_name(signoz, admin_token, _TARGET_B)
resp = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
@@ -184,12 +181,11 @@ def test_delete_scoped_to_granted_role(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
a_id = find_role_id(admin_token, _TARGET_A)
b_id = find_role_id(admin_token, _TARGET_B)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
a_id = find_role_by_name(signoz, admin_token, _TARGET_A)
b_id = find_role_by_name(signoz, admin_token, _TARGET_B)
resp = requests.put(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{actor_id}"),
@@ -221,11 +217,10 @@ def test_revoke_read(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
actor_id = find_role_id(admin_token, _ACTOR_ROLE_NAME)
b_id = find_role_id(admin_token, _TARGET_B)
actor_id = find_role_by_name(signoz, admin_token, _ACTOR_ROLE_NAME)
b_id = find_role_by_name(signoz, admin_token, _TARGET_B)
token = get_token(_ACTOR_USER_EMAIL, _ACTOR_USER_PASSWORD)
resp = requests.put(
@@ -261,7 +256,6 @@ def test_role_fga_cleanup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
user = find_user_by_email(signoz, admin_token, _ACTOR_USER_EMAIL)
@@ -279,7 +273,7 @@ def test_role_fga_cleanup(
for name in (_ACTOR_ROLE_NAME, _TARGET_B, _CREATED_ROLE):
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_id(admin_token, name)}"),
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_by_name(signoz, admin_token, name)}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)

View File

@@ -13,7 +13,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import transaction_group
from fixtures.role import find_role_by_name, transaction_group
from fixtures.savedview import SAVED_VIEW_BASE, create_saved_view, find_saved_view_by_name
_SAVED_VIEW_FGA_CUSTOM_ROLE_NAME = "saved-view-fga-readonly"
@@ -57,7 +57,7 @@ def test_create_custom_role_readonly_view(
signoz,
admin_token,
email=_SAVED_VIEW_FGA_CUSTOM_USER_EMAIL,
role="VIEWER",
role="signoz-viewer",
password=_SAVED_VIEW_FGA_CUSTOM_USER_PASSWORD,
name="saved-view-fga-test-user",
)
@@ -152,10 +152,9 @@ def test_create_is_collection_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
target_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_TARGET_NAME)["id"]
resp = requests.put(
@@ -184,10 +183,9 @@ def test_update_scoped_to_granted_view(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
target_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_TARGET_NAME)["id"]
other_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_OTHER_NAME)["id"]
@@ -242,10 +240,9 @@ def test_delete_scoped_to_granted_view(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
target_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_TARGET_NAME)["id"]
other_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_OTHER_NAME)["id"]
@@ -279,10 +276,9 @@ def test_revoke_read_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)
other_id = find_saved_view_by_name(signoz, admin_token, _SAVED_VIEW_FGA_OTHER_NAME)["id"]
resp = requests.put(
@@ -319,7 +315,6 @@ def test_saved_view_fga_cleanup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
user = find_user_by_email(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_USER_EMAIL)
@@ -336,7 +331,7 @@ def test_saved_view_fga_cleanup(
assert resp.status_code == HTTPStatus.NO_CONTENT, f"remove role from user: {resp.text}"
resp = requests.delete(
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_id(admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)}"),
signoz.self.host_configs["8080"].get(f"/api/v1/roles/{find_role_by_name(signoz, admin_token, _SAVED_VIEW_FGA_CUSTOM_ROLE_NAME)}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)

View File

@@ -13,7 +13,7 @@ from fixtures.auth import (
create_active_user,
find_user_by_email,
)
from fixtures.role import transaction_group
from fixtures.role import find_role_by_name, transaction_group
from fixtures.serviceaccount import (
SERVICE_ACCOUNT_BASE,
create_service_account,
@@ -70,7 +70,7 @@ def test_create_custom_role_readonly_sa(
signoz,
admin_token,
email=_SA_FGA_CUSTOM_USER_EMAIL,
role="VIEWER",
role="signoz-viewer",
password=_SA_FGA_CUSTOM_USER_PASSWORD,
name="sa-fga-test-user",
)
@@ -116,12 +116,11 @@ def test_write_forbidden_without_grant(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
token = get_token(_SA_FGA_CUSTOM_USER_EMAIL, _SA_FGA_CUSTOM_USER_PASSWORD)
target_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_TARGET_SA_NAME)["id"]
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
resp = requests.put(
signoz.self.host_configs["8080"].get(f"{SERVICE_ACCOUNT_BASE}/{target_id}"),
@@ -144,10 +143,9 @@ def test_update_scoped_to_granted_sa(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_by_name(signoz, admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
target_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_TARGET_SA_NAME)["id"]
other_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_OTHER_SA_NAME)["id"]
@@ -191,14 +189,13 @@ def test_attach_detach_dual_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_by_name(signoz, admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
target_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_TARGET_SA_NAME)["id"]
other_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_OTHER_SA_NAME)["id"]
editor_role_id = find_role_id(admin_token, "signoz-editor")
viewer_role_id = find_role_id(admin_token, "signoz-viewer")
editor_role_id = find_role_by_name(signoz, admin_token, "signoz-editor")
viewer_role_id = find_role_by_name(signoz, admin_token, "signoz-viewer")
# attach/detach granted on the target SA id AND the signoz-editor role name only.
resp = requests.put(
@@ -261,10 +258,9 @@ def test_revoke_read_scoped(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_by_name(signoz, admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
target_id = find_service_account_by_name(signoz, admin_token, _SA_FGA_TARGET_SA_NAME)["id"]
resp = requests.put(
@@ -284,10 +280,9 @@ def test_delete_custom_role_cleanup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
find_role_id: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
role_id = find_role_id(admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
role_id = find_role_by_name(signoz, admin_token, _SA_FGA_CUSTOM_ROLE_NAME)
user = find_user_by_email(signoz, admin_token, _SA_FGA_CUSTOM_USER_EMAIL)
resp = requests.get(signoz.self.host_configs["8080"].get(f"/api/v2/users/{user['id']}/roles"), headers={"Authorization": f"Bearer {admin_token}"}, timeout=5)