diff --git a/OPENAPI_DOC.yml b/OPENAPI_DOC.yml index 7d86d93d..17b177c7 100644 --- a/OPENAPI_DOC.yml +++ b/OPENAPI_DOC.yml @@ -21,7 +21,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -33,23 +34,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -613,7 +611,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -625,23 +624,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -1115,7 +1111,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -1127,23 +1124,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -1681,7 +1675,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -1693,23 +1688,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -2168,7 +2160,8 @@ paths: parameters: - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -2180,23 +2173,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -2683,7 +2673,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -2695,23 +2686,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -3237,7 +3225,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -3249,23 +3238,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -4034,7 +4020,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -4046,23 +4033,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -4528,7 +4512,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -4540,23 +4525,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -5022,7 +5004,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -5034,23 +5017,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -6118,7 +6098,8 @@ paths: parameters: - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -6130,23 +6111,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -7028,7 +7006,8 @@ paths: parameters: - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -7040,23 +7019,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -7612,7 +7588,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -7624,23 +7601,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -8410,7 +8384,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -8422,23 +8397,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -9023,7 +8995,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -9035,23 +9008,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -10296,7 +10266,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -10308,23 +10279,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -12081,7 +12049,8 @@ paths: parameters: - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -12093,23 +12062,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -13704,16 +13670,14 @@ paths: format: Int32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -14276,21 +14240,20 @@ paths: format: Int32 - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -14463,21 +14426,20 @@ paths: format: Int32 - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -14886,21 +14848,20 @@ paths: format: Int32 - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -15399,21 +15360,20 @@ paths: format: Int32 - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -16899,7 +16859,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -16911,23 +16872,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -17379,7 +17337,7 @@ paths: $ref: '#/components/schemas/PlaceOS__Api__Application__ContentError' /api/engine/v2/emails: get: - summary: list queued/processed mail, filtered via Elasticsearch + summary: list queued/processed mail tags: - PendingMails operationId: PlaceOS::Api::PendingMails_index @@ -17474,7 +17432,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -17486,23 +17445,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -18075,7 +18031,8 @@ paths: type: string - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -18087,23 +18044,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -18270,7 +18224,8 @@ paths: parameters: - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -18282,23 +18237,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -20364,14 +20316,21 @@ paths: $ref: '#/components/schemas/PlaceOS__Api__Application__ContentError' /api/engine/v2/reindex: post: - summary: maps the database tables to indexes in elasticsearch + summary: 'DEPRECATED: search is served directly from PostgreSQL (PPT-2644); + there' + description: 'DEPRECATED: search is served directly from PostgreSQL (PPT-2644); + there + + is no external index to rebuild. Kept as a no-op so existing admin UIs + + and automation don''t error. Remove in a future release.' tags: - Root operationId: PlaceOS::Api::Root_reindex parameters: - name: backfill in: query - description: backfill the database after re-indexing? + description: deprecated, has no effect example: "true" schema: type: boolean @@ -20436,7 +20395,14 @@ paths: $ref: '#/components/schemas/PlaceOS__Api__Application__ContentError' /api/engine/v2/backfill: post: - summary: pushes all the data from the database into elasticsearch + summary: 'DEPRECATED: search is served directly from PostgreSQL (PPT-2644); + the' + description: 'DEPRECATED: search is served directly from PostgreSQL (PPT-2644); + the + + database is always authoritative. Kept as a no-op so existing admin UIs + + and automation don''t error. Remove in a future release.' tags: - Root operationId: PlaceOS::Api::Root_backfill @@ -20512,7 +20478,8 @@ paths: parameters: - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -20524,23 +20491,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -20999,7 +20963,8 @@ paths: parameters: - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -21011,23 +20976,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -21842,16 +21804,14 @@ paths: format: Int32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -22615,16 +22575,14 @@ paths: format: Int32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -23861,7 +23819,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -23873,23 +23832,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -24382,21 +24338,20 @@ paths: format: Int32 - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -24927,16 +24882,14 @@ paths: format: Int32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -26051,7 +26004,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -26063,23 +26017,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -26578,7 +26529,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -26590,23 +26542,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -27108,7 +27057,8 @@ paths: type: string - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -27120,23 +27070,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -27304,6 +27251,90 @@ paths: application/json: schema: $ref: '#/components/schemas/PlaceOS__Api__Application__ContentError' + /api/engine/v2/admin_consent/flow/{flow_id}: + get: + summary: progress of an in-flight admin-consent flow, polled by the progress + page. + description: 'progress of an in-flight admin-consent flow, polled by the progress + page. + + The flow id is an unguessable capability token; the payload contains no + + secrets.' + tags: + - TenantConsent + operationId: PlaceOS::Api::TenantConsent_flow_status + parameters: + - name: flow_id + in: path + description: Flow identifier issued by the consent callback + example: uuid-1234 + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__AdminConsentFlow' + 409: + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__CommonError' + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__CommonError' + 503: + description: Service Unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__CommonError' + 403: + description: Forbidden + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__CommonError' + 406: + description: Not Acceptable + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__ContentError' + 408: + description: Request Timeout + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__CommonError' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__ParameterError' + 422: + description: Unprocessable Entity + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__ParameterError' + 415: + description: Unsupported Media Type + content: + application/json: + schema: + $ref: '#/components/schemas/PlaceOS__Api__Application__ContentError' /api/engine/v2/triggers: get: summary: returns the list of available triggers @@ -27313,7 +27344,8 @@ paths: parameters: - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -27325,23 +27357,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -27922,21 +27951,20 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -29095,7 +29123,8 @@ paths: nullable: true - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -29107,23 +29136,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -30503,7 +30529,8 @@ paths: parameters: - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -30515,23 +30542,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -30710,7 +30734,8 @@ paths: type: boolean - name: q in: query - description: returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html) + description: filters results by the given text; words match as prefixes against + the resource's searchable fields and all words must match schema: type: string - name: limit @@ -30722,23 +30747,20 @@ paths: format: UInt32 - name: offset in: query - description: deprecated, the starting offset of the result set. Used to implement - pagination + description: the starting offset of the result set, used to implement pagination schema: type: integer format: UInt32 - name: ref in: query - description: a token for accessing the next page of results, provided in the - `Link` header + description: deprecated, ignored — pagination follows the `Link` header's + offset schema: type: string nullable: true - name: fields in: query - description: (Optional, comma separated array of strings) Array of fields - you wish to search. Accepts wildcard expresssions and boost relevance score - for matches for particular field using a caret ^ operator. + description: deprecated, ignored — search covers the resource's indexed fields schema: type: array items: @@ -39374,6 +39396,56 @@ components: type: string required: - url + PlaceOS__Api__AdminConsentFlow: + type: object + properties: + id: + type: string + state: + type: string + steps: + type: array + items: + type: object + properties: + key: + type: string + label: + type: string + state: + type: string + required: + - key + - label + - state + detail: + type: string + nullable: true + error: + type: string + nullable: true + redirect: + type: string + authority_id: + type: string + updated_at: + type: integer + format: Int64 + required: + - id + - state + - steps + - redirect + - authority_id + - updated_at + description: 'Progress state for an in-flight Azure admin-consent flow. + + + The consent callback spawns the Microsoft Graph work into a fiber and + + responds immediately with a progress page; state lives in redis (with a + + TTL) so any replica can answer the page''s polling.' PlaceOS__Model__Trigger: type: object properties: diff --git a/docker-compose.yml b/docker-compose.yml index 3c6abba3..b8d1ef3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,6 @@ x-deployment-env: &deployment-env ENV: ${ENV:-development} SG_ENV: ${SG_ENV:-development} -x-elastic-client-env: &elastic-client-env - ELASTIC_HOST: ${ELASTIC_HOST:-elastic} - ELASTIC_PORT: ${ELASTIC_PORT:-9200} - x-redis-client-env: &redis-client-env REDIS_URL: ${REDIS_URL:-redis://redis:6379} @@ -19,9 +15,6 @@ x-postgresdb-client-env: &postgresdb-client-env PG_DATABASE: ${PG_DATABASE:-place_development} PG_DATABASE_URL: ${PG_DATABASE_URL:-postgresql://postgres:password@postgres:5432/place_development} -x-search-ingest-client-env: &search-ingest-client-env - SEARCH_INGEST_URI: ${SEARCH_INGEST_URI:-http://search-ingest:3000} - x-s3-client-env: &s3-client-env AWS_REGION: ${AWS_REGION:-"us-east-1"} AWS_KEY: ${AWS_KEY:-"root"} @@ -47,11 +40,9 @@ services: - ${PWD}/coverage:/app/coverage depends_on: - core - - elastic - redis - postgres - migrator - - search-ingest security_opt: - seccomp:unconfined environment: @@ -60,25 +51,10 @@ services: <<: [ *deployment-env, # Service Hosts - *elastic-client-env, *redis-client-env, *postgresdb-client-env, ] - elastic: - image: elasticsearch:${ELASTIC_VERSION:-7.17.6} - restart: always - hostname: elastic - healthcheck: - test: wget -q --no-verbose --tries=1 --spider http://localhost:9200/_cat/health - environment: - discovery.type: single-node - ES_JAVA_OPTS: -Xms1g -Xmx1g - http.cors.enabled: "true" - http.cors.allow-origin: http://localhost:8080 - ports: - - 9200:9200 - redis: image: eqalpha/keydb restart: always @@ -114,24 +90,6 @@ services: PG_DATABASE_URL: ${PG_DATABASE_URL:-postgresql://postgres:password@postgres:5432/place_development} - search-ingest: # PostgreSQL to Elasticsearch Service - image: placeos/search-ingest:nightly - restart: always - hostname: search-ingest - depends_on: - - elastic - - migrator - - postgres - environment: - LOG_LEVEL: trace - # Service Hosts - <<: [ - *postgresdb-client-env, - *elastic-client-env, - # Environment - *deployment-env, - ] - core: # Module coordinator image: placeos/core:nightly restart: always diff --git a/shard.lock b/shard.lock index 173a36dc..b112ebb4 100644 --- a/shard.lock +++ b/shard.lock @@ -223,7 +223,7 @@ shards: placeos-models: git: https://github.com/placeos/models.git - version: 9.105.2 + version: 9.107.1 placeos-resource: git: https://github.com/place-labs/resource.git @@ -273,10 +273,6 @@ shards: git: https://github.com/straight-shoota/sanitize.git version: 0.1.0+git.commit.c17d933fed22c6d3d615cca91a895166414389af - search-ingest: - git: https://github.com/placeos/search-ingest.git - version: 2.11.3+git.commit.fc43c1f8ad8733b6a6aa829e6def8a4e6756cf18 - secrets-env: # Overridden git: https://github.com/spider-gazelle/secrets-env.git version: 1.3.1 diff --git a/shard.yml b/shard.yml index 678f8d67..2d999ef2 100644 --- a/shard.yml +++ b/shard.yml @@ -31,11 +31,6 @@ dependencies: github: crystal-community/jwt version: ~> 1 - # Elasticsearch extension for PgORM - neuroplastic: - github: spider-gazelle/neuroplastic - version: ~> 1.14 - # Performs ping requests pinger: github: spider-gazelle/pinger @@ -74,11 +69,6 @@ dependencies: pg-orm: github: spider-gazelle/pg-orm - # Client for Search Ingest PostgreSQL Elasticsearch indexer - search-ingest: - github: placeos/search-ingest - branch: master - # Scheduling tasker: github: spider-gazelle/tasker diff --git a/spec/controllers/alert_dashboards_spec.cr b/spec/controllers/alert_dashboards_spec.cr index b7f0e97a..d117f5da 100644 --- a/spec/controllers/alert_dashboards_spec.cr +++ b/spec/controllers/alert_dashboards_spec.cr @@ -6,6 +6,61 @@ module PlaceOS::Api describe "index", tags: "search" do Spec.test_base_index(Model::AlertDashboard, AlertDashboards) + + it "filters by authority_id" do + other_authority = PlaceOS::Model::Generator.authority("other-#{random_name}.example.com") + other_authority.save! + + other_dashboard = PlaceOS::Model::Generator.alert_dashboard(name: "Other Authority Dashboard", description: "Test Description", authority_id: other_authority.id) + other_dashboard.save! + + local_dashboard = PlaceOS::Model::Generator.alert_dashboard(name: "Local Dashboard", description: "Test Description") + local_dashboard.save! + + params = HTTP::Params.encode({"authority_id" => other_authority.id.as(String)}) + result = client.get( + "#{AlertDashboards.base_route}?#{params}", + headers: Spec::Authentication.headers + ) + + result.success?.should be_true + dashboards = Array(Hash(String, JSON::Any)).from_json(result.body) + ids = dashboards.map(&.["id"].as_s) + ids.should contain(other_dashboard.id) + ids.should_not contain(local_dashboard.id) + dashboards.each &.["authority_id"].as_s.should eq other_authority.id + + other_dashboard.destroy + local_dashboard.destroy + other_authority.destroy + end + + it "limits non-support users to their own authority's dashboards" do + other_authority = PlaceOS::Model::Generator.authority("other-#{random_name}.example.com") + other_authority.save! + + foreign_dashboard = PlaceOS::Model::Generator.alert_dashboard(name: "Foreign Dashboard", description: "Test Description", authority_id: other_authority.id) + foreign_dashboard.save! + + # generator defaults to the localhost authority the test user belongs to + own_dashboard = PlaceOS::Model::Generator.alert_dashboard(name: "Own Dashboard", description: "Test Description") + own_dashboard.save! + + params = HTTP::Params.encode({"limit" => "1000"}) + result = client.get( + "#{AlertDashboards.base_route}?#{params}", + headers: Spec::Authentication.headers(sys_admin: false, support: false) + ) + + result.success?.should be_true + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(own_dashboard.id) + ids.should_not contain(foreign_dashboard.id) + + foreign_dashboard.destroy + own_dashboard.destroy + other_authority.destroy + end end describe "CRUD operations", tags: "crud" do diff --git a/spec/controllers/alerts_spec.cr b/spec/controllers/alerts_spec.cr index d5da4b9e..88766d37 100644 --- a/spec/controllers/alerts_spec.cr +++ b/spec/controllers/alerts_spec.cr @@ -51,10 +51,6 @@ module PlaceOS::Api describe "filtering", tags: "search" do it "filters by severity" do - # ensure the enum columns are mapped before search-ingest indexes the new - # alerts (the pinned search-ingest image doesn't map enums; see helper) - ensure_keyword_mapping(Model::Alert.table_name, ["severity", "alert_type"]) - dashboard = PlaceOS::Model::Generator.alert_dashboard(name: "Test Dashboard", description: "Test Description") dashboard.save! @@ -64,11 +60,8 @@ module PlaceOS::Api low_alert = PlaceOS::Model::Generator.alert(name: "Low Alert", description: "Low Priority Alert", alert_dashboard_id: dashboard.id, severity: PlaceOS::Model::Alert::Severity::LOW) low_alert.save! - # make the new alerts searchable, then scope to this test's dashboard so the - # count is deterministic regardless of other alerts in the shared ES index - sleep 1.second - refresh_elastic(Model::Alert.table_name) - + # scope to this test's dashboard so the count is deterministic + # regardless of alerts created by other tests params = HTTP::Params.encode({"severity" => "HIGH", "alert_dashboard_id" => dashboard.id.as(String)}) result = client.get( "#{Alerts.base_route}?#{params}", @@ -78,15 +71,12 @@ module PlaceOS::Api result.success?.should be_true alerts = Array(Hash(String, JSON::Any)).from_json(result.body) alerts.size.should eq 1 + alerts.first["id"].as_s.should eq high_alert.id # the API serializes the enum as its lower-cased member name alerts.first["severity"].as_s.should eq "high" end it "filters by alert type" do - # ensure the enum columns are mapped before search-ingest indexes the new - # alerts (the pinned search-ingest image doesn't map enums; see helper) - ensure_keyword_mapping(Model::Alert.table_name, ["severity", "alert_type"]) - dashboard = PlaceOS::Model::Generator.alert_dashboard(name: "Test Dashboard", description: "Test Description") dashboard.save! @@ -96,11 +86,6 @@ module PlaceOS::Api status_alert = PlaceOS::Model::Generator.alert(name: "Status Alert", description: "Status Alert", alert_dashboard_id: dashboard.id, alert_type: PlaceOS::Model::Alert::AlertType::STATUS) status_alert.save! - # make the new alerts searchable, then scope to this test's dashboard so the - # count is deterministic regardless of other alerts in the shared ES index - sleep 1.second - refresh_elastic(Model::Alert.table_name) - params = HTTP::Params.encode({"alert_type" => "THRESHOLD", "alert_dashboard_id" => dashboard.id.as(String)}) result = client.get( "#{Alerts.base_route}?#{params}", @@ -110,9 +95,104 @@ module PlaceOS::Api result.success?.should be_true alerts = Array(Hash(String, JSON::Any)).from_json(result.body) alerts.size.should eq 1 + alerts.first["id"].as_s.should eq threshold_alert.id # the API serializes the enum as its lower-cased member name alerts.first["alert_type"].as_s.should eq "threshold" end + + it "filters by enabled status, including enabled=false" do + dashboard = PlaceOS::Model::Generator.alert_dashboard(name: "Test Dashboard", description: "Test Description") + dashboard.save! + + enabled_alert = PlaceOS::Model::Generator.alert(name: "Enabled Alert", description: "Enabled Alert", alert_dashboard_id: dashboard.id, enabled: true) + enabled_alert.save! + + disabled_alert = PlaceOS::Model::Generator.alert(name: "Disabled Alert", description: "Disabled Alert", alert_dashboard_id: dashboard.id, enabled: false) + disabled_alert.save! + + # enabled=false previously matched everything (the Elasticsearch-era + # filter was skipped for falsy values) — it must now filter + params = HTTP::Params.encode({"enabled" => "false", "alert_dashboard_id" => dashboard.id.as(String)}) + result = client.get( + "#{Alerts.base_route}?#{params}", + headers: Spec::Authentication.headers + ) + + result.success?.should be_true + alerts = Array(Hash(String, JSON::Any)).from_json(result.body) + alerts.size.should eq 1 + alerts.first["id"].as_s.should eq disabled_alert.id + alerts.first["enabled"].as_bool.should be_false + + params = HTTP::Params.encode({"enabled" => "true", "alert_dashboard_id" => dashboard.id.as(String)}) + result = client.get( + "#{Alerts.base_route}?#{params}", + headers: Spec::Authentication.headers + ) + + result.success?.should be_true + alerts = Array(Hash(String, JSON::Any)).from_json(result.body) + alerts.size.should eq 1 + alerts.first["id"].as_s.should eq enabled_alert.id + alerts.first["enabled"].as_bool.should be_true + end + + it "combines q with filters" do + dashboard = PlaceOS::Model::Generator.alert_dashboard(name: "Test Dashboard", description: "Test Description") + dashboard.save! + + name = random_name + named_alert = PlaceOS::Model::Generator.alert(name: name, description: "Named Alert", alert_dashboard_id: dashboard.id, severity: PlaceOS::Model::Alert::Severity::CRITICAL) + named_alert.save! + + other_alert = PlaceOS::Model::Generator.alert(name: "Other Alert", description: "Other Alert", alert_dashboard_id: dashboard.id, severity: PlaceOS::Model::Alert::Severity::CRITICAL) + other_alert.save! + + params = HTTP::Params.encode({ + "q" => name, + "severity" => "critical", + "alert_dashboard_id" => dashboard.id.as(String), + }) + result = client.get( + "#{Alerts.base_route}?#{params}", + headers: Spec::Authentication.headers + ) + + result.success?.should be_true + alerts = Array(Hash(String, JSON::Any)).from_json(result.body) + alerts.size.should eq 1 + alerts.first["id"].as_s.should eq named_alert.id + end + end + + describe "non-support scoping", tags: "search" do + it "returns alerts across all of the authority's dashboards" do + # two dashboards in the caller's authority (generator defaults to the + # localhost authority the test user belongs to), one alert on each — + # the Elasticsearch version ANDed the dashboard ids and returned + # nothing for authorities with more than one dashboard; this pins the + # IN() semantics + dashboard_one = PlaceOS::Model::Generator.alert_dashboard(name: "Scoped Dashboard One", description: "Test Description") + dashboard_one.save! + dashboard_two = PlaceOS::Model::Generator.alert_dashboard(name: "Scoped Dashboard Two", description: "Test Description") + dashboard_two.save! + + alert_one = PlaceOS::Model::Generator.alert(name: "Scoped Alert One", description: "Test", alert_dashboard_id: dashboard_one.id) + alert_one.save! + alert_two = PlaceOS::Model::Generator.alert(name: "Scoped Alert Two", description: "Test", alert_dashboard_id: dashboard_two.id) + alert_two.save! + + params = HTTP::Params.encode({"limit" => "1000"}) + result = client.get( + "#{Alerts.base_route}?#{params}", + headers: Spec::Authentication.headers(sys_admin: false, support: false) + ) + + result.success?.should be_true + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(alert_one.id) + ids.should contain(alert_two.id) + end end end end diff --git a/spec/controllers/api_key_spec.cr b/spec/controllers/api_key_spec.cr index a0057bce..a526da1f 100644 --- a/spec/controllers/api_key_spec.cr +++ b/spec/controllers/api_key_spec.cr @@ -9,6 +9,35 @@ module PlaceOS::Api describe "index", tags: "search" do Spec.test_base_index(Model::ApiKey, ApiKeys) + + it "filters by authority_id" do + other_authority = PlaceOS::Model::Generator.authority("other-#{random_name}.example.com") + other_authority.save! + + other_key = PlaceOS::Model::Generator.api_key(other_authority) + other_key.save! + + # a key under the default (localhost) authority must be excluded + local_key = PlaceOS::Model::Generator.api_key + local_key.save! + + params = HTTP::Params.encode({"authority_id" => other_authority.id.as(String)}) + result = client.get( + "#{ApiKeys.base_route}?#{params}", + headers: Spec::Authentication.headers + ) + + result.status_code.should eq 200 + keys = Array(Hash(String, JSON::Any)).from_json(result.body) + ids = keys.map(&.["id"].as_s) + ids.should contain(other_key.id) + ids.should_not contain(local_key.id) + keys.each &.["authority_id"].as_s.should eq other_authority.id + + other_key.destroy + local_key.destroy + other_authority.destroy + end end describe "CRUD operations", tags: "crud" do diff --git a/spec/controllers/asset_categories_spec.cr b/spec/controllers/asset_categories_spec.cr index 7c621a5e..addb6350 100644 --- a/spec/controllers/asset_categories_spec.cr +++ b/spec/controllers/asset_categories_spec.cr @@ -1,11 +1,52 @@ require "../helper" +# asserts a successful asset categories index response and extracts the returned ids +def asset_category_index_ids(result) : Array(String) + result.status_code.should eq 200 + Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].to_s) +end + module PlaceOS::Api describe AssetCategories do Spec.test_404(AssetCategories.base_route, model_name: Model::AssetCategory.table_name, headers: Spec::Authentication.headers, clz: Int64) describe "index", tags: "search" do Spec.test_base_index(Model::AssetCategory, AssetCategories) + + it "filters categories by hidden status, including hidden=false", tags: "search" do + _, headers = Spec::Authentication.authentication + + visible = PlaceOS::Model::Generator.asset_category + visible.hidden = false + visible.save! + + concealed = PlaceOS::Model::Generator.asset_category + concealed.hidden = true + concealed.save! + + base = AssetCategories.base_route.rstrip('/') + + # hidden=false returns only non-hidden categories (the Elasticsearch + # implementation silently ignored `hidden=false`; this pins the fix) + ids = asset_category_index_ids(client.get("#{base}?hidden=false&limit=1000", headers: headers)) + ids.should contain(visible.id) + ids.should_not contain(concealed.id) + + # hidden=true returns only hidden categories + ids = asset_category_index_ids(client.get("#{base}?hidden=true&limit=1000", headers: headers)) + ids.should contain(concealed.id) + ids.should_not contain(visible.id) + + # no hidden param returns all categories + ids = asset_category_index_ids(client.get("#{base}?limit=1000", headers: headers)) + ids.should contain(visible.id) + ids.should contain(concealed.id) + + visible.destroy + concealed.destroy + end end describe "CRUD operations", tags: "crud" do diff --git a/spec/controllers/asset_purchase_orders_spec.cr b/spec/controllers/asset_purchase_orders_spec.cr index 95838bfe..e65ba19b 100644 --- a/spec/controllers/asset_purchase_orders_spec.cr +++ b/spec/controllers/asset_purchase_orders_spec.cr @@ -1,5 +1,13 @@ require "../helper" +# asserts a successful purchase orders index response and extracts the returned ids +def purchase_order_index_ids(result) : Array(String) + result.status_code.should eq 200 + Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].to_s) +end + module PlaceOS::Api describe AssetPurchaseOrders do Spec.test_404(AssetPurchaseOrders.base_route, model_name: Model::AssetPurchaseOrder.table_name, headers: Spec::Authentication.headers, clz: Int64) @@ -11,20 +19,37 @@ module PlaceOS::Api purchase_order_number = random_name doc.purchase_order_number = purchase_order_number doc.save! + doc.persisted?.should be_true - refresh_elastic(Model::AssetPurchaseOrder.table_name) + other = PlaceOS::Model::Generator.asset_purchase_order.save! - doc.persisted?.should be_true - params = HTTP::Params.encode({"q" => purchase_order_number}) + params = HTTP::Params.encode({"q" => purchase_order_number, "limit" => "1000"}) + path = "#{AssetPurchaseOrders.base_route.rstrip('/')}?#{params}" + ids = purchase_order_index_ids(client.get(path, headers: headers)) + ids.should contain(doc.id) + ids.should_not contain(other.id) + + doc.destroy + other.destroy + end + + it "queries AssetPurchaseOrder by invoice number", tags: "search" do + _, headers = Spec::Authentication.authentication + doc = PlaceOS::Model::Generator.asset_purchase_order + invoice_number = random_name + doc.invoice_number = invoice_number + doc.save! + + other = PlaceOS::Model::Generator.asset_purchase_order.save! + + params = HTTP::Params.encode({"q" => invoice_number, "limit" => "1000"}) path = "#{AssetPurchaseOrders.base_route.rstrip('/')}?#{params}" + ids = purchase_order_index_ids(client.get(path, headers: headers)) + ids.should contain(doc.id) + ids.should_not contain(other.id) - found = until_expected("GET", path, headers) do |response| - Array(Hash(String, JSON::Any)) - .from_json(response.body) - .map(&.["id"].to_s) - .any?(doc.id) - end - found.should be_true + doc.destroy + other.destroy end end diff --git a/spec/controllers/assets_spec.cr b/spec/controllers/assets_spec.cr index 53c07ef2..b2477fe2 100644 --- a/spec/controllers/assets_spec.cr +++ b/spec/controllers/assets_spec.cr @@ -1,5 +1,13 @@ require "../helper" +# asserts a successful assets index response and extracts the returned ids +def asset_index_ids(result) : Array(String) + result.status_code.should eq 200 + Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].to_s) +end + module PlaceOS::Api describe Assets do Spec.test_404(Assets.base_route, model_name: Model::Asset.table_name, headers: Spec::Authentication.headers, clz: Int64) @@ -11,59 +19,210 @@ module PlaceOS::Api identifier = random_name doc.identifier = identifier doc.save! - - refresh_elastic(Model::Asset.table_name) doc.persisted?.should be_true + base = Assets.base_route.rstrip('/') + # search for the asset directly - params = HTTP::Params.encode({"q" => identifier}) - path = "#{Assets.base_route.rstrip('/')}?#{params}" - found = until_expected("GET", path, headers) do |response| - Array(Hash(String, JSON::Any)) - .from_json(response.body) - .map(&.["id"].to_s) - .any?(doc.id) - end - found.should be_true - - # don't use until expected once the doc is indexed - client = ActionController::SpecHelper.client - - # search for asset using asset type + params = HTTP::Params.encode({"q" => identifier, "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(doc.id) + + # filter for the asset using its asset type type_id = doc.asset_type_id.to_s - params = HTTP::Params.encode({"type_id" => type_id}) - path = "#{Assets.base_route.rstrip('/')}?#{params}" - response = client.exec(method: "GET", path: path, headers: headers) - found = Array(Hash(String, JSON::Any)) - .from_json(response.body) - .map(&.["id"].to_s) - .any?(doc.id) - found.should be_true - - # search for asset using asset type + params = HTTP::Params.encode({"type_id" => type_id, "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(doc.id) + + # q keeps working combined with a filter + params = HTTP::Params.encode({"q" => identifier, "type_id" => type_id, "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(doc.id) + + # an asset type id that can't exist matches nothing params = HTTP::Params.encode({"type_id" => "invalid_id"}) - path = "#{Assets.base_route.rstrip('/')}?#{params}" - response = client.exec(method: "GET", path: path, headers: headers) - found = Array(Hash(String, JSON::Any)) - .from_json(response.body) - .map(&.["id"].to_s) - .any?(doc.id) - found.should be_false + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should be_empty # search for something else params = HTTP::Params.encode({"q" => "xxxxxxxxxx"}) - path = "#{Assets.base_route.rstrip('/')}?#{params}" - response = client.exec(method: "GET", path: path, headers: headers) - found = Array(Hash(String, JSON::Any)) - .from_json(response.body) - .map(&.["id"].to_s) - .any?(doc.id) - found.should be_false - - # TODO:: search for asset using the asset type name - # type_name = doc.asset_type.not_nil!.name - # params = HTTP::Params.encode({"q" => type_name}) - # path = "#{Assets.base_route.rstrip('/')}?#{params}" + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should_not contain(doc.id) + end + + it "searches for assets using the asset type name", tags: "search" do + _, headers = Spec::Authentication.authentication + + asset_type = PlaceOS::Model::Generator.asset_type + type_name = random_name + asset_type.name = type_name + asset_type.save! + + doc = PlaceOS::Model::Generator.asset(asset_type: asset_type).save! + other = PlaceOS::Model::Generator.asset.save! + + params = HTTP::Params.encode({"q" => type_name, "limit" => "1000"}) + ids = asset_index_ids(client.get("#{Assets.base_route.rstrip('/')}?#{params}", headers: headers)) + ids.should contain(doc.id) + ids.should_not contain(other.id) + + doc.destroy + other.destroy + end + + it "filters assets by zone_id and zones (asset must be in every listed zone)", tags: "search" do + _, headers = Spec::Authentication.authentication + + zone_a = PlaceOS::Model::Generator.zone.save! + zone_b = PlaceOS::Model::Generator.zone.save! + zone_a_id = zone_a.id.as(String) + zone_b_id = zone_b.id.as(String) + + asset_ab = PlaceOS::Model::Generator.asset + asset_ab.zone_id = zone_a_id + asset_ab.zones = [zone_a_id, zone_b_id] + asset_ab.save! + + asset_b = PlaceOS::Model::Generator.asset + asset_b.zone_id = zone_b_id + asset_b.zones = [zone_b_id] + asset_b.save! + + base = Assets.base_route.rstrip('/') + + # zone_id is an exact match on the asset's primary zone + params = HTTP::Params.encode({"zone_id" => zone_a_id, "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(asset_ab.id) + ids.should_not contain(asset_b.id) + + # zones requires membership of every listed zone (AND semantics) + params = HTTP::Params.encode({"zones" => "#{zone_a_id},#{zone_b_id}", "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(asset_ab.id) + ids.should_not contain(asset_b.id) + + # a single zone matches every asset within it + params = HTTP::Params.encode({"zones" => zone_b_id, "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(asset_ab.id) + ids.should contain(asset_b.id) + + asset_ab.destroy + asset_b.destroy + zone_a.destroy + zone_b.destroy + end + + it "filters assets by barcode and serial number", tags: "search" do + _, headers = Spec::Authentication.authentication + + doc = PlaceOS::Model::Generator.asset + doc.barcode = "barcode-#{random_name}" + doc.serial_number = "serial-#{random_name}" + doc.save! + other = PlaceOS::Model::Generator.asset.save! + + base = Assets.base_route.rstrip('/') + + params = HTTP::Params.encode({"barcode" => doc.barcode.as(String), "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(doc.id) + ids.should_not contain(other.id) + + params = HTTP::Params.encode({"serial_number" => doc.serial_number.as(String), "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(doc.id) + ids.should_not contain(other.id) + + doc.destroy + other.destroy + end + + it "filters assets by purchase order id", tags: "search" do + _, headers = Spec::Authentication.authentication + + purchase_order = PlaceOS::Model::Generator.asset_purchase_order.save! + doc = PlaceOS::Model::Generator.asset(purchase_order: purchase_order).save! + other = PlaceOS::Model::Generator.asset.save! + + base = Assets.base_route.rstrip('/') + + params = HTTP::Params.encode({"order_id" => purchase_order.id.to_s, "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(doc.id) + ids.should_not contain(other.id) + + # a purchase order id that can't exist matches nothing + params = HTTP::Params.encode({"order_id" => "invalid_id"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should be_empty + + doc.destroy + other.destroy + end + + it "filters assets by bookable and accessible, including false values", tags: "search" do + _, headers = Spec::Authentication.authentication + + # generator defaults: bookable = true, accessible = false + default_asset = PlaceOS::Model::Generator.asset.save! + + flipped_asset = PlaceOS::Model::Generator.asset + flipped_asset.bookable = false + flipped_asset.accessible = true + flipped_asset.save! + + base = Assets.base_route.rstrip('/') + + params = HTTP::Params.encode({"bookable" => "false", "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(flipped_asset.id) + ids.should_not contain(default_asset.id) + + params = HTTP::Params.encode({"accessible" => "false", "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(default_asset.id) + ids.should_not contain(flipped_asset.id) + + params = HTTP::Params.encode({"accessible" => "true", "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(flipped_asset.id) + ids.should_not contain(default_asset.id) + + default_asset.destroy + flipped_asset.destroy + end + + it "filters assets by features, matching any listed feature", tags: "search" do + _, headers = Spec::Authentication.authentication + + feature_a = "feature-#{random_name}" + feature_b = "feature-#{random_name}" + + asset_a = PlaceOS::Model::Generator.asset + asset_a.features = [feature_a] + asset_a.save! + + asset_b = PlaceOS::Model::Generator.asset + asset_b.features = [feature_b] + asset_b.save! + + base = Assets.base_route.rstrip('/') + + # multiple features are ORed (Elasticsearch should + minimum_should_match(1)) + params = HTTP::Params.encode({"features" => "#{feature_a},#{feature_b}", "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(asset_a.id) + ids.should contain(asset_b.id) + + params = HTTP::Params.encode({"features" => feature_a, "limit" => "1000"}) + ids = asset_index_ids(client.get("#{base}?#{params}", headers: headers)) + ids.should contain(asset_a.id) + ids.should_not contain(asset_b.id) + + asset_a.destroy + asset_b.destroy end end diff --git a/spec/controllers/authentications_spec.cr b/spec/controllers/authentications_spec.cr new file mode 100644 index 00000000..cdc237c1 --- /dev/null +++ b/spec/controllers/authentications_spec.cr @@ -0,0 +1,68 @@ +require "../helper" + +module PlaceOS::Api + # The Ldap/Saml/OAuth authentication controllers are generated by a single + # macro (controllers/authentications.cr) — exercise all three the same way. + # NOTE: the Saml generator is `adfs_strat` (returns a SamlAuthentication). + {% for entry in [{"Ldap", "ldap_strat"}, {"Saml", "adfs_strat"}, {"OAuth", "oauth_strat"}] %} + describe {{entry[0].id}}Authentications do + base = {{entry[0].id}}Authentications.base_route + + describe "index", tags: "search" do + it "lists authentication strategies" do + strat = Model::Generator.{{entry[1].id}}.save! + strat.persisted?.should be_true + + result = client.get(base, headers: Spec::Authentication.headers) + result.status_code.should eq 200 + + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(strat.id) + result.headers["X-Total-Count"].to_i64.should be >= 1 + end + + it "filters by authority_id" do + authority_a = Model::Generator.authority("a-#{random_name}.example.com").save! + authority_b = Model::Generator.authority("b-#{random_name}.example.com").save! + + strat_a = Model::Generator.{{entry[1].id}}(authority_a).save! + strat_b = Model::Generator.{{entry[1].id}}(authority_b).save! + + params = HTTP::Params.encode({"authority_id" => authority_a.id.as(String)}) + result = client.get("#{base}?#{params}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + + results = Array(Hash(String, JSON::Any)).from_json(result.body) + results.should_not be_empty + ids = results.map(&.["id"].as_s) + ids.should contain(strat_a.id) + ids.should_not contain(strat_b.id) + results.all? { |r| r["authority_id"].as_s == authority_a.id }.should be_true + end + + it "filters by name via q" do + strat = Model::Generator.{{entry[1].id}} + strat.name = random_name + strat.save! + + params = HTTP::Params.encode({"q" => strat.name}) + result = client.get("#{base}?#{params}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + + results = Array(Hash(String, JSON::Any)).from_json(result.body) + results.size.should eq 1 + results.first["id"].as_s.should eq strat.id + result.headers["X-Total-Count"].should eq "1" + end + end + + describe "access control", tags: "access-control" do + it "is admin-only" do + _, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + result = client.get(base, headers: headers) + result.status_code.should eq 403 + end + end + end + {% end %} +end diff --git a/spec/controllers/domains_spec.cr b/spec/controllers/domains_spec.cr index ff6ef7fa..f009e7c9 100644 --- a/spec/controllers/domains_spec.cr +++ b/spec/controllers/domains_spec.cr @@ -2,6 +2,38 @@ require "../helper" module PlaceOS::Api describe Domains do + describe "index", tags: "search" do + it "lists domains with pagination headers" do + authority_a = Model::Generator.authority("a-#{random_name}.example.com").save! + authority_b = Model::Generator.authority("b-#{random_name}.example.com").save! + + result = client.get(Domains.base_route, headers: Spec::Authentication.headers) + result.status_code.should eq 200 + + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(authority_a.id) + ids.should contain(authority_b.id) + + result.headers["X-Total-Count"].to_i64.should eq Model::Authority.count + end + + it "filters domains via q" do + target = Model::Generator.authority("q-#{random_name}.example.com") + target.name = random_name + target.save! + Model::Generator.authority("other-#{random_name}.example.com").save! + + params = HTTP::Params.encode({"q" => target.name}) + result = client.get("#{Domains.base_route}?#{params}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + + domains = Array(Hash(String, JSON::Any)).from_json(result.body) + domains.size.should eq 1 + domains.first["id"].as_s.should eq target.id + result.headers["X-Total-Count"].should eq "1" + end + end + it "Lookup domain via user email" do authority = Model::Generator.authority("https://www.dev-placeos.com", ["placeos.com", "dev-placeos.com"]).save! email = URI.encode_www_form("test@placeos.com") diff --git a/spec/controllers/drivers_spec.cr b/spec/controllers/drivers_spec.cr index 89a987e1..302e7689 100644 --- a/spec/controllers/drivers_spec.cr +++ b/spec/controllers/drivers_spec.cr @@ -15,16 +15,13 @@ module PlaceOS::Api "q" => service.name, }) - refresh_elastic(Model::Driver.table_name) path = "#{Drivers.base_route}?#{params}" - found = until_expected("GET", path, Spec::Authentication.headers) do |response| - results = Array(Hash(String, JSON::Any)).from_json(response.body) - all_service_roles = results.all? { |r| r["role"] == Model::Driver::Role::Service.to_i } - contains_search_term = results.any? { |r| r["id"] == service.id } - !results.empty? && all_service_roles && contains_search_term - end - - found.should be_true + response = client.get(path, headers: Spec::Authentication.headers) + response.status_code.should eq 200 + results = Array(Hash(String, JSON::Any)).from_json(response.body) + results.should_not be_empty + results.all? { |r| r["role"] == Model::Driver::Role::Service.to_i }.should be_true + results.any? { |r| r["id"] == service.id }.should be_true end end diff --git a/spec/controllers/modules_spec.cr b/spec/controllers/modules_spec.cr index 7bac7132..9284986e 100644 --- a/spec/controllers/modules_spec.cr +++ b/spec/controllers/modules_spec.cr @@ -135,18 +135,12 @@ module PlaceOS::Api doc = Model::Generator.module(driver: driver).save! doc.persisted?.should be_true - refresh_elastic(Model::Module.table_name) - params = HTTP::Params.encode({"q" => name}) path = "#{Modules.base_route.rstrip('/')}?#{params}" - header = Spec::Authentication.headers - found = until_expected("GET", path, header) do |response| - Array(Hash(String, JSON::Any)).from_json(response.body).any? do |result| - result["id"].as_s == doc.id - end - end - - found.should be_true + response = client.get(path, headers: Spec::Authentication.headers) + response.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) + ids.should contain(doc.id) end it "looks up by system_id" do @@ -168,23 +162,22 @@ module PlaceOS::Api end it "ensures management users can only see the modules they have access to" do - # before_each clears the DB but not ES; this assertion counts the - # unscoped index, so start from a clean ES slate to avoid stale docs - # from earlier examples inflating the management-visible count. - clear_elastic(Model::Module.table_name) - Model::Generator.module.save! Model::Generator.module.save! Model::Generator.module.save! - mod = Model::Generator.module.save! + # The scoped whole-list deliberately excludes logic modules + # (`mod.role <> 99` in the zone-scope CTE, inherited from the + # group-permissions work) — and Generator.module rolls a RANDOM driver + # role, so the visible module must be pinned to a non-logic role or + # this example fails ~1 run in 5. + service_driver = Model::Generator.driver(role: Model::Driver::Role::Service).save! + mod = Model::Generator.module(driver: service_driver).save! sys = Model::Generator.control_system sys.zones << Spec::Authentication.org_zone.id.as(String) sys.zones_will_change! sys.modules = [mod.id.as(String)] sys.save! - refresh_elastic(Model::Module.table_name) - # ensure regular users can't use the route params = HTTP::Params{"control_system_id" => sys.id.as(String)} response = client.get( @@ -202,22 +195,15 @@ module PlaceOS::Api response.status_code.should eq 200 # Admins can see everything - header = Spec::Authentication.headers - until_expected("GET", Modules.base_route, header) do |resp| - response = resp - resp.success? ? (resp.headers["X-Total-Count"].to_i > 1) : false - end + response = client.get(Modules.base_route, headers: Spec::Authentication.headers) response.status_code.should eq 200 - response.headers["X-Total-Count"].to_i > 1 + response.headers["X-Total-Count"].to_i.should be > 1 # Management can only see the one - header = Spec::Authentication.headers(sys_admin: false, support: false, groups: ["management"]) - until_expected("GET", Modules.base_route, header) do |resp| - response = resp - resp.success? ? (resp.headers["X-Total-Count"].to_i > 0) : false - end - - # check the results + response = client.get( + Modules.base_route, + headers: Spec::Authentication.headers(sys_admin: false, support: false, groups: ["management"]), + ) response.status_code.should eq 200 response.headers["X-Total-Count"].should eq("1") Array(Hash(String, JSON::Any)).from_json(response.body.to_s).map(&.["id"].as_s).first?.should eq(mod.id) @@ -240,14 +226,11 @@ module PlaceOS::Api params = HTTP::Params.encode({"as_of" => (mod1.updated_at.try &.to_unix).to_s}) path = "#{Modules.base_route}?#{params}" - found = until_expected("GET", path, Spec::Authentication.headers) do |response| - results = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - contains_correct = results.any?(mod1.id) - contains_incorrect = results.any?(mod2.id) - !results.empty? && contains_correct && !contains_incorrect - end - - found.should be_true + response = client.get(path, headers: Spec::Authentication.headers) + response.status_code.should eq 200 + results = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) + results.should contain(mod1.id) + results.should_not contain(mod2.id) end it "no_logic" do @@ -259,16 +242,12 @@ module PlaceOS::Api params = HTTP::Params.encode({"no_logic" => "true"}) path = "#{Modules.base_route}?#{params}" - found = until_expected("GET", path, Spec::Authentication.headers) do |response| - results = Array(Hash(String, JSON::Any)).from_json(response.body) - - no_logic = results.all? { |r| r["role"].as_i != Model::Driver::Role::Logic.to_i } - contains_created = results.any? { |r| r["id"].as_s == mod.id } - - !results.empty? && no_logic && contains_created - end - - found.should be_true + response = client.get(path, headers: Spec::Authentication.headers) + response.status_code.should eq 200 + results = Array(Hash(String, JSON::Any)).from_json(response.body) + results.should_not be_empty + results.all? { |r| r["role"].as_i != Model::Driver::Role::Logic.to_i }.should be_true + results.any? { |r| r["id"].as_s == mod.id }.should be_true end end @@ -287,18 +266,14 @@ module PlaceOS::Api doc_two = Model::Generator.module(driver: driver_two).save! doc_two.persisted?.should be_true - refresh_elastic(Model::Module.table_name) - params = HTTP::Params.encode({"q" => name_one}) path = "#{Modules.base_route.rstrip('/')}?#{params}" - header = Spec::Authentication.headers - found = until_expected("GET", path, header) do |response| - result = Array(Hash(String, JSON::Any)).from_json(response.body) - result.any? { |r| r["id"].as_s == doc_one.id } && !result.any? { |r| r["id"].as_s == doc_two.id } ? true : false - end - - found.should be_true + response = client.get(path, headers: Spec::Authentication.headers) + response.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) + ids.should contain(doc_one.id) + ids.should_not contain(doc_two.id) end end diff --git a/spec/controllers/oauth_applications_spec.cr b/spec/controllers/oauth_applications_spec.cr index c13d7260..3e46373e 100644 --- a/spec/controllers/oauth_applications_spec.cr +++ b/spec/controllers/oauth_applications_spec.cr @@ -1,5 +1,13 @@ require "../helper" +# GET the oauth application index, returning the ids in response order. +# The PG-backed search is synchronous — no retry polling needed. +def oauth_app_index_ids(path : String, headers : HTTP::Headers) : Array(String) + response = client.get(path, headers: headers) + response.status_code.should eq 200 + Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_i64.to_s) +end + module PlaceOS::Api describe OAuthApplications do base = OAuthApplications.base_route @@ -9,6 +17,10 @@ module PlaceOS::Api Model::DoorkeeperApplication.clear end + describe "index", tags: "search" do + Spec.test_base_index(Model::DoorkeeperApplication, OAuthApplications) + end + describe "index access" do it "lets sys_admin see every application across authorities" do common_authority = Model::Authority.find_by_domain("localhost").not_nil! @@ -17,15 +29,22 @@ module PlaceOS::Api own = Model::Generator.doorkeeper_application(owner: common_authority).save! foreign = Model::Generator.doorkeeper_application(owner: other_authority).save! - sleep 1.second - refresh_elastic(Model::DoorkeeperApplication.table_name) - found = until_expected("GET", base, Spec::Authentication.headers) do |response| - response.success? && begin - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_i.to_s) - ids.includes?(own.id.to_s) && ids.includes?(foreign.id.to_s) - end - end - found.should be_true + ids = oauth_app_index_ids(base, Spec::Authentication.headers) + ids.should contain(own.id.to_s) + ids.should contain(foreign.id.to_s) + end + + it "sys_admin can filter by authority_id" do + common_authority = Model::Authority.find_by_domain("localhost").not_nil! + other_authority = Model::Generator.authority(domain: "http://other-#{Random::Secure.hex(3)}.example").save! + + own = Model::Generator.doorkeeper_application(owner: common_authority).save! + foreign = Model::Generator.doorkeeper_application(owner: other_authority).save! + + params = HTTP::Params.encode({"authority_id" => other_authority.id.as(String)}) + ids = oauth_app_index_ids("#{base}?#{params}", Spec::Authentication.headers) + ids.should contain(foreign.id.to_s) + ids.should_not contain(own.id.to_s) end it "regular user with no subsystem_access only sees apps without subsystems" do @@ -35,15 +54,9 @@ module PlaceOS::Api common = Model::Generator.doorkeeper_application(owner: authority).save! signage = Model::Generator.doorkeeper_application(owner: authority, subsystems: ["signage"]).save! - sleep 1.second - refresh_elastic(Model::DoorkeeperApplication.table_name) - found = until_expected("GET", base, headers) do |response| - response.success? && begin - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_i.to_s) - ids.includes?(common.id.to_s) && !ids.includes?(signage.id.to_s) - end - end - found.should be_true + ids = oauth_app_index_ids(base, headers) + ids.should contain(common.id.to_s) + ids.should_not contain(signage.id.to_s) end it "regular user with subsystems sees their matching apps and the common ones, not unrelated subsystems" do @@ -57,17 +70,10 @@ module PlaceOS::Api events = Model::Generator.doorkeeper_application(owner: authority, subsystems: ["events"]).save! common = Model::Generator.doorkeeper_application(owner: authority).save! - sleep 1.second - refresh_elastic(Model::DoorkeeperApplication.table_name) - found = until_expected("GET", base, headers) do |response| - response.success? && begin - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_i.to_s) - ids.includes?(signage.id.to_s) && - ids.includes?(common.id.to_s) && - !ids.includes?(events.id.to_s) - end - end - found.should be_true + ids = oauth_app_index_ids(base, headers) + ids.should contain(signage.id.to_s) + ids.should contain(common.id.to_s) + ids.should_not contain(events.id.to_s) end it "ignores authority_id from non-admin callers (forces own authority)" do @@ -81,16 +87,29 @@ module PlaceOS::Api # honoured for non-admin callers, the caller could enumerate. foreign_common = Model::Generator.doorkeeper_application(owner: other_authority).save! - sleep 1.second - refresh_elastic(Model::DoorkeeperApplication.table_name) params = HTTP::Params.encode({"authority_id" => other_authority.id.as(String)}) - found = until_expected("GET", "#{base}?#{params}", headers) do |response| - response.success? && begin - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_i.to_s) - ids.includes?(own_common.id.to_s) && !ids.includes?(foreign_common.id.to_s) - end - end - found.should be_true + ids = oauth_app_index_ids("#{base}?#{params}", headers) + ids.should contain(own_common.id.to_s) + ids.should_not contain(foreign_common.id.to_s) + end + + it "q search combines with the non-admin subsystem gating" do + authority = Model::Authority.find_by_domain("localhost").not_nil! + _, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + target_name = "app #{random_name}" + common = Model::Generator.doorkeeper_application(owner: authority, name: target_name).save! + other_common = Model::Generator.doorkeeper_application(owner: authority).save! + # matches q but is subsystem-gated away from this user + gated = Model::Generator.doorkeeper_application( + owner: authority, name: "#{target_name} gated", subsystems: ["signage"] + ).save! + + params = HTTP::Params.encode({"q" => target_name}) + ids = oauth_app_index_ids("#{base}?#{params}", headers) + ids.should contain(common.id.to_s) + ids.should_not contain(other_common.id.to_s) + ids.should_not contain(gated.id.to_s) end end diff --git a/spec/controllers/pending_mails_spec.cr b/spec/controllers/pending_mails_spec.cr index 3fc96faa..76f8d7df 100644 --- a/spec/controllers/pending_mails_spec.cr +++ b/spec/controllers/pending_mails_spec.cr @@ -11,6 +11,7 @@ def create_pending_mail( rejected_at : Time? = nil, expiry : Time? = nil, send_at : Time? = nil, + template : Array(String)? = nil, ) : PlaceOS::Model::PendingMail mail = PlaceOS::Model::Generator.pending_mail(authority: authority, user: user) mail.source_service = source_service @@ -20,6 +21,7 @@ def create_pending_mail( mail.rejected_at = rejected_at mail.expiry = expiry mail.send_at = send_at + mail.template = template if template mail.save! mail end @@ -29,15 +31,13 @@ def other_authority : PlaceOS::Model::Authority PlaceOS::Model::Generator.authority(domain: "http://other-#{random_name}.test").save! end -# GET the index with the supplied params, retrying until `block` holds -# (Elasticsearch indexing is asynchronous). -def pending_mail_index_ids(params : Hash(String, String), headers : HTTP::Headers, &block : Array(String) -> Bool) : Bool +# GET the index with the supplied params, returning the ids in response +# order. The PG-backed search is synchronous — no retry polling needed. +def pending_mail_index_ids(params : Hash(String, String), headers : HTTP::Headers) : Array(String) path = "#{PlaceOS::Api::PendingMails.base_route.rstrip('/')}?#{HTTP::Params.encode(params)}" - until_expected("GET", path, headers) do |response| - next false unless response.success? - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - block.call(ids) - end + response = client.get(path, headers: headers) + response.status_code.should eq 200 + Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) end module PlaceOS::Api @@ -51,12 +51,9 @@ module PlaceOS::Api match = create_pending_mail(source_service: svc) other = create_pending_mail(source_service: "svc-#{random_name}") - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - - pending_mail_index_ids({"source_service" => svc}, headers) do |ids| - ids.includes?(match.id.to_s) && !ids.includes?(other.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc}, headers) + ids.should contain(match.id.to_s) + ids.should_not contain(other.id.to_s) match.destroy other.destroy @@ -68,12 +65,9 @@ module PlaceOS::Api match = create_pending_mail(source_reference: ref) other = create_pending_mail(source_reference: "ref-#{random_name}") - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - - pending_mail_index_ids({"source_reference" => ref}, headers) do |ids| - ids.includes?(match.id.to_s) && !ids.includes?(other.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_reference" => ref}, headers) + ids.should contain(match.id.to_s) + ids.should_not contain(other.id.to_s) match.destroy other.destroy @@ -87,12 +81,9 @@ module PlaceOS::Api match = create_pending_mail(source_service: svc, user: user) other = create_pending_mail(source_service: svc) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - - pending_mail_index_ids({"source_service" => svc, "user_id" => user.id.to_s}, headers) do |ids| - ids.includes?(match.id.to_s) && !ids.includes?(other.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc, "user_id" => user.id.to_s}, headers) + ids.should contain(match.id.to_s) + ids.should_not contain(other.id.to_s) match.destroy other.destroy @@ -106,33 +97,43 @@ module PlaceOS::Api match = create_pending_mail(source_service: svc, zones: [zone]) other = create_pending_mail(source_service: svc, zones: ["zone-#{random_name}"]) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - - pending_mail_index_ids({"source_service" => svc, "zones" => zone}, headers) do |ids| - ids.includes?(match.id.to_s) && !ids.includes?(other.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc, "zones" => zone}, headers) + ids.should contain(match.id.to_s) + ids.should_not contain(other.id.to_s) match.destroy other.destroy end + it "multiple zones require the mail to reference all of them (AND)" do + headers = Spec::Authentication.headers + svc = "svc-#{random_name}" + zone_a = "zone-#{random_name}" + zone_b = "zone-#{random_name}" + both = create_pending_mail(source_service: svc, zones: [zone_a, zone_b]) + only_a = create_pending_mail(source_service: svc, zones: [zone_a]) + + ids = pending_mail_index_ids({"source_service" => svc, "zones" => "#{zone_a},#{zone_b}"}, headers) + ids.should contain(both.id.to_s) + ids.should_not contain(only_a.id.to_s) + + both.destroy + only_a.destroy + end + it "excludes rejected mail by default and includes it on request" do headers = Spec::Authentication.headers svc = "svc-#{random_name}" pending = create_pending_mail(source_service: svc) rejected = create_pending_mail(source_service: svc, rejected_at: Time.utc) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - - pending_mail_index_ids({"source_service" => svc}, headers) do |ids| - ids.includes?(pending.id.to_s) && !ids.includes?(rejected.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc}, headers) + ids.should contain(pending.id.to_s) + ids.should_not contain(rejected.id.to_s) - pending_mail_index_ids({"source_service" => svc, "include_rejected" => "true"}, headers) do |ids| - ids.includes?(pending.id.to_s) && ids.includes?(rejected.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc, "include_rejected" => "true"}, headers) + ids.should contain(pending.id.to_s) + ids.should contain(rejected.id.to_s) pending.destroy rejected.destroy @@ -145,12 +146,10 @@ module PlaceOS::Api sent = create_pending_mail(source_service: svc, sent_at: Time.utc) rejected = create_pending_mail(source_service: svc, rejected_at: Time.utc) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - - pending_mail_index_ids({"source_service" => svc, "unsent_only" => "true"}, headers) do |ids| - ids.includes?(pending.id.to_s) && !ids.includes?(sent.id.to_s) && !ids.includes?(rejected.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc, "unsent_only" => "true"}, headers) + ids.should contain(pending.id.to_s) + ids.should_not contain(sent.id.to_s) + ids.should_not contain(rejected.id.to_s) pending.destroy sent.destroy @@ -164,16 +163,15 @@ module PlaceOS::Api future = create_pending_mail(source_service: svc, expiry: Time.utc + 1.hour) expired = create_pending_mail(source_service: svc, expiry: Time.utc - 1.hour) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) + ids = pending_mail_index_ids({"source_service" => svc}, headers) + ids.should contain(no_expiry.id.to_s) + ids.should contain(future.id.to_s) + ids.should_not contain(expired.id.to_s) - pending_mail_index_ids({"source_service" => svc}, headers) do |ids| - ids.includes?(no_expiry.id.to_s) && ids.includes?(future.id.to_s) && !ids.includes?(expired.id.to_s) - end.should be_true - - pending_mail_index_ids({"source_service" => svc, "include_expired" => "true"}, headers) do |ids| - ids.includes?(no_expiry.id.to_s) && ids.includes?(future.id.to_s) && ids.includes?(expired.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc, "include_expired" => "true"}, headers) + ids.should contain(no_expiry.id.to_s) + ids.should contain(future.id.to_s) + ids.should contain(expired.id.to_s) no_expiry.destroy future.destroy @@ -186,31 +184,47 @@ module PlaceOS::Api old_mail = create_pending_mail(source_service: svc, sent_at: Time.utc - 10.days) recent = create_pending_mail(source_service: svc, sent_at: Time.utc - 1.hour) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - cutoff = (Time.utc - 1.day).to_rfc3339 - pending_mail_index_ids({"source_service" => svc, "sent_after" => cutoff}, headers) do |ids| - ids.includes?(recent.id.to_s) && !ids.includes?(old_mail.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc, "sent_after" => cutoff}, headers) + ids.should contain(recent.id.to_s) + ids.should_not contain(old_mail.id.to_s) old_mail.destroy recent.destroy end + it "filters by a sent_after..sent_before window" do + headers = Spec::Authentication.headers + svc = "svc-#{random_name}" + too_old = create_pending_mail(source_service: svc, sent_at: Time.utc - 10.days) + in_window = create_pending_mail(source_service: svc, sent_at: Time.utc - 12.hours) + too_new = create_pending_mail(source_service: svc, sent_at: Time.utc - 1.minute) + + params = { + "source_service" => svc, + "sent_after" => (Time.utc - 1.day).to_rfc3339, + "sent_before" => (Time.utc - 1.hour).to_rfc3339, + } + ids = pending_mail_index_ids(params, headers) + ids.should contain(in_window.id.to_s) + ids.should_not contain(too_old.id.to_s) + ids.should_not contain(too_new.id.to_s) + + too_old.destroy + in_window.destroy + too_new.destroy + end + it "sent window also matches rejected time when include_rejected is set" do headers = Spec::Authentication.headers svc = "svc-#{random_name}" recently_rejected = create_pending_mail(source_service: svc, rejected_at: Time.utc - 1.hour) old_rejected = create_pending_mail(source_service: svc, rejected_at: Time.utc - 10.days) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - cutoff = (Time.utc - 1.day).to_rfc3339 - pending_mail_index_ids({"source_service" => svc, "sent_after" => cutoff, "include_rejected" => "true"}, headers) do |ids| - ids.includes?(recently_rejected.id.to_s) && !ids.includes?(old_rejected.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc, "sent_after" => cutoff, "include_rejected" => "true"}, headers) + ids.should contain(recently_rejected.id.to_s) + ids.should_not contain(old_rejected.id.to_s) recently_rejected.destroy old_rejected.destroy @@ -222,13 +236,10 @@ module PlaceOS::Api soon = create_pending_mail(source_service: svc, send_at: Time.utc + 1.hour) later = create_pending_mail(source_service: svc, send_at: Time.utc + 10.days) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - cutoff = (Time.utc + 1.day).to_rfc3339 - pending_mail_index_ids({"source_service" => svc, "send_at_after" => cutoff}, headers) do |ids| - ids.includes?(later.id.to_s) && !ids.includes?(soon.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc, "send_at_after" => cutoff}, headers) + ids.should contain(later.id.to_s) + ids.should_not contain(soon.id.to_s) soon.destroy later.destroy @@ -246,12 +257,9 @@ module PlaceOS::Api match = create_pending_mail(source_service: svc, zones: [zone.id.as(String)]) other = create_pending_mail(source_service: svc, zones: ["zone-#{random_name}"]) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - - pending_mail_index_ids({"source_service" => svc, "group_id" => group.id.to_s}, headers) do |ids| - ids.includes?(match.id.to_s) && !ids.includes?(other.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc, "group_id" => group.id.to_s}, headers) + ids.should contain(match.id.to_s) + ids.should_not contain(other.id.to_s) match.destroy other.destroy @@ -268,12 +276,9 @@ module PlaceOS::Api mine = create_pending_mail(authority: local_auth, source_service: svc) theirs = create_pending_mail(authority: other_auth, source_service: svc) - sleep 1.second - refresh_elastic(Model::PendingMail.table_name) - - pending_mail_index_ids({"source_service" => svc}, scoped_headers) do |ids| - ids.includes?(mine.id.to_s) && !ids.includes?(theirs.id.to_s) - end.should be_true + ids = pending_mail_index_ids({"source_service" => svc}, scoped_headers) + ids.should contain(mine.id.to_s) + ids.should_not contain(theirs.id.to_s) # explicitly targeting another authority is forbidden for non-support path = "#{PendingMails.base_route.rstrip('/')}?#{HTTP::Params.encode({"authority_id" => other_auth.id.to_s})}" @@ -283,6 +288,41 @@ module PlaceOS::Api theirs.destroy other_auth.destroy end + + it "q searches template text (prefix match) and combines with filters" do + headers = Spec::Authentication.headers + svc = "svc-#{random_name}" + token = "tmpl#{random_name}" + match = create_pending_mail(source_service: svc, template: [token, "email"]) + other = create_pending_mail(source_service: svc) + + ids = pending_mail_index_ids({"q" => token, "source_service" => svc}, headers) + ids.should contain(match.id.to_s) + ids.should_not contain(other.id.to_s) + + # prefix matching (parity with the trailing * Elasticsearch appended) + prefix_ids = pending_mail_index_ids({"q" => token[0, 8], "source_service" => svc}, headers) + prefix_ids.should contain(match.id.to_s) + + # a q that matches nothing returns an empty result + pending_mail_index_ids({"q" => "zzz#{random_name}", "source_service" => svc}, headers).should be_empty + + match.destroy + other.destroy + end + + it "returns newest mail first (created_at DESC)" do + headers = Spec::Authentication.headers + svc = "svc-#{random_name}" + first = create_pending_mail(source_service: svc) + second = create_pending_mail(source_service: svc) + third = create_pending_mail(source_service: svc) + + ids = pending_mail_index_ids({"source_service" => svc}, headers) + ids.should eq [third.id.to_s, second.id.to_s, first.id.to_s] + + [first, second, third].each(&.destroy) + end end describe "show" do diff --git a/spec/controllers/schema_spec.cr b/spec/controllers/schema_spec.cr new file mode 100644 index 00000000..23634baf --- /dev/null +++ b/spec/controllers/schema_spec.cr @@ -0,0 +1,33 @@ +require "../helper" + +module PlaceOS::Api + describe Schema do + Spec.test_404(Schema.base_route, model_name: Model::JsonSchema.table_name, headers: Spec::Authentication.headers, clz: String) + + describe "index", tags: "search" do + Spec.test_base_index(Model::JsonSchema, Schema) + + it "lists schemas with a total count" do + schema = PlaceOS::Model::Generator.json_schema + name = random_name + schema.name = name + schema.save! + + result = client.get(Schema.base_route, headers: Spec::Authentication.headers) + result.status_code.should eq 200 + result.headers["X-Total-Count"].to_i.should be >= 1 + + # a unique q scopes the count to exactly this record + params = HTTP::Params.encode({"q" => name}) + result = client.get("#{Schema.base_route}?#{params}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + result.headers["X-Total-Count"].should eq "1" + schemas = Array(Hash(String, JSON::Any)).from_json(result.body) + schemas.size.should eq 1 + schemas.first["id"].as_s.should eq schema.id + + schema.destroy + end + end + end +end diff --git a/spec/controllers/settings_spec.cr b/spec/controllers/settings_spec.cr index 800aebd3..48219ea3 100644 --- a/spec/controllers/settings_spec.cr +++ b/spec/controllers/settings_spec.cr @@ -38,13 +38,12 @@ module PlaceOS::Api describe "index", tags: "search" do it "searches on keys" do - unencrypted = %({"secret_key": "secret1234"}) + key = "skey#{Random.rand(99999)}" + value = "sval#{Random.rand(99999)}" + unencrypted = %({"#{key}": "#{value}"}) settings = Model::Generator.settings(settings_string: unencrypted).save! - sleep 1.seconds - refresh_elastic(Model::Settings.table_name) - - params = HTTP::Params.encode({"q" => settings.keys.first}) + params = HTTP::Params.encode({"q" => key}) path = "#{Settings.base_route.rstrip('/')}?#{params}" result = client.get( @@ -54,9 +53,48 @@ module PlaceOS::Api result.status_code.should eq 200 - settings = Array(Model::Settings).from_json(result.body) - settings.should_not be_empty - settings.first.keys.should contain("secret_key") + returned = Array(Model::Settings).from_json(result.body) + returned.map(&.id).should contain(settings.id) + returned.first.keys.should contain(key) + + # only the keys are searchable — the settings body content is not + params = HTTP::Params.encode({"q" => value}) + result = client.get( + path: "#{Settings.base_route.rstrip('/')}?#{params}", + headers: Spec::Authentication.headers + ) + + result.status_code.should eq 200 + Array(Model::Settings).from_json(result.body).map(&.id).should_not contain(settings.id) + end + + # Pins Elasticsearch-parity behavior: the search branch returns + # settings as stored, without `decrypt_for!` (unlike the parent_id + # branch, which decrypts for the requesting user). + it "does not decrypt settings in the search branch" do + key = "ekey#{Random.rand(99999)}" + setting = Model::Generator.settings( + settings_string: %({"#{key}": "value"}), + encryption_level: Encryption::Level::Admin, + ).save! + + params = HTTP::Params.encode({"q" => key}) + result = client.get( + path: "#{Settings.base_route.rstrip('/')}?#{params}", + headers: Spec::Authentication.headers + ) + + result.status_code.should eq 200 + returned = Array(Model::Settings).from_json(result.body) + returned.map(&.id).should contain(setting.id) + returned.find! { |s| s.id == setting.id }.is_encrypted?.should be_true + end + + it "rejects the search branch for non-support users" do + _, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + result = client.get(path: Settings.base_route, headers: headers) + result.status_code.should eq 403 end it "returns settings for a set of parent ids" do @@ -70,8 +108,6 @@ module PlaceOS::Api sys, sys2 = systems - refresh_elastic(Model::Settings.table_name) - result = client.get( path: File.join(Settings.base_route, "?parent_id=#{sys.id},#{sys2.id}"), headers: Spec::Authentication.headers @@ -98,7 +134,6 @@ module PlaceOS::Api Model::Generator.settings(encryption_level: Encryption::Level::NeverDisplay, control_system: sys), ] clear, admin, never_displayed = settings.map(&.save!) - refresh_elastic(Model::Settings.table_name) result = client.get( path: File.join(Settings.base_route, "?parent_id=#{sys.id}"), diff --git a/spec/controllers/short_url_spec.cr b/spec/controllers/short_url_spec.cr index f6696a1e..eb607607 100644 --- a/spec/controllers/short_url_spec.cr +++ b/spec/controllers/short_url_spec.cr @@ -19,5 +19,45 @@ module PlaceOS::Api uri.destroy end end + + describe "GET /short_url", tags: "search" do + it "lists the short URLs for the domain" do + Model::Shortener.clear + headers = Spec::Authentication.headers + + url1 = Model::Generator.shortener("https://example.com/#{random_name}").save! + url2 = Model::Generator.shortener("https://example.com/#{random_name}").save! + + result = client.get(path: ShortURL.base_route, headers: headers) + result.status_code.should eq 200 + + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(url1.id.as(String)) + ids.should contain(url2.id.as(String)) + result.headers["X-Total-Count"].should eq "2" + end + + it "filters the listing with q" do + Model::Shortener.clear + headers = Spec::Authentication.headers + + target = random_name + match = Model::Generator.shortener("https://example.com/#{target}").save! + other = Model::Generator.shortener("https://example.com/#{random_name}").save! + + params = HTTP::Params.encode({"q" => target}) + result = client.get(path: "#{ShortURL.base_route}?#{params}", headers: headers) + result.status_code.should eq 200 + + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(match.id.as(String)) + ids.should_not contain(other.id.as(String)) + result.headers["X-Total-Count"].should eq "1" + end + end end end diff --git a/spec/controllers/signage_plugins_spec.cr b/spec/controllers/signage_plugins_spec.cr index e36590c2..3edd8ceb 100644 --- a/spec/controllers/signage_plugins_spec.cr +++ b/spec/controllers/signage_plugins_spec.cr @@ -114,9 +114,6 @@ module PlaceOS::Api plugin.save! plugin_id = plugin.id.as(String) - sleep 1.second - refresh_elastic(Model::SignagePlugin.table_name) - result = client.get( path: SignagePlugins.base_route, headers: headers, @@ -167,6 +164,122 @@ module PlaceOS::Api updated.name.should eq "Updated Shared Plugin" updated.authority_id.should be_nil end + + it "GET / lists this domain's plugins and filters with q", tags: "search" do + headers = Spec::Authentication.headers + + target = random_name + match = Model::Generator.signage_plugin(name: target).save! + other = Model::Generator.signage_plugin(name: random_name).save! + + result = client.get(path: SignagePlugins.base_route, headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(match.id.as(String)) + ids.should contain(other.id.as(String)) + result.headers["X-Total-Count"].should eq "2" + + params = HTTP::Params.encode({"q" => target}) + result = client.get(path: "#{SignagePlugins.base_route}?#{params}", headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(match.id.as(String)) + ids.should_not contain(other.id.as(String)) + result.headers["X-Total-Count"].should eq "1" + end + + it "GET / excludes other authorities' plugins", tags: "search" do + headers = Spec::Authentication.headers + + mine = Model::Generator.signage_plugin.save! + + shared = Model::Generator.signage_plugin + shared.authority_id = nil + shared.save! + + other_authority = Model::Generator.authority("https://other-#{random_name}.example.com").save! + foreign = Model::Generator.signage_plugin(authority: other_authority).save! + + result = client.get(path: SignagePlugins.base_route, headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(mine.id.as(String)) + ids.should contain(shared.id.as(String)) + ids.should_not contain(foreign.id.as(String)) + result.headers["X-Total-Count"].should eq "2" + end + + it "GET /?enabled= filters on both true and false", tags: "search" do + headers = Spec::Authentication.headers + + enabled_name = random_name + enabled_plugin = Model::Generator.signage_plugin(name: enabled_name) + enabled_plugin.enabled = true + enabled_plugin.save! + + disabled_plugin = Model::Generator.signage_plugin + disabled_plugin.enabled = false + disabled_plugin.save! + + result = client.get(path: "#{SignagePlugins.base_route}?enabled=true", headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(enabled_plugin.id.as(String)) + ids.should_not contain(disabled_plugin.id.as(String)) + + # enabled=false must filter too, not be ignored + result = client.get(path: "#{SignagePlugins.base_route}?enabled=false", headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(disabled_plugin.id.as(String)) + ids.should_not contain(enabled_plugin.id.as(String)) + + # q combines with the enabled filter + params = HTTP::Params.encode({"q" => enabled_name, "enabled" => "true"}) + result = client.get(path: "#{SignagePlugins.base_route}?#{params}", headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should eq [enabled_plugin.id.as(String)] + + params = HTTP::Params.encode({"q" => enabled_name, "enabled" => "false"}) + result = client.get(path: "#{SignagePlugins.base_route}?#{params}", headers: headers) + result.status_code.should eq 200 + Array(JSON::Any).from_json(result.body).should be_empty + result.headers["X-Total-Count"].should eq "0" + end + + it "GET / paginates deterministically by name", tags: "search" do + headers = Spec::Authentication.headers + + first = Model::Generator.signage_plugin(name: "aaa-#{random_name}").save! + second = Model::Generator.signage_plugin(name: "bbb-#{random_name}").save! + + result = client.get(path: "#{SignagePlugins.base_route}?limit=1", headers: headers) + result.status_code.should eq 200 + page = Array(Hash(String, JSON::Any)).from_json(result.body) + page.size.should eq 1 + page.first["id"].as_s.should eq first.id.as(String) + result.headers["X-Total-Count"].should eq "2" + result.headers["Link"]?.should_not be_nil + + result = client.get(path: "#{SignagePlugins.base_route}?limit=1&offset=1", headers: headers) + result.status_code.should eq 200 + page = Array(Hash(String, JSON::Any)).from_json(result.body) + page.size.should eq 1 + page.first["id"].as_s.should eq second.id.as(String) + end end end end diff --git a/spec/controllers/system-triggers_spec.cr b/spec/controllers/system-triggers_spec.cr index 8a050b72..1535978a 100644 --- a/spec/controllers/system-triggers_spec.cr +++ b/spec/controllers/system-triggers_spec.cr @@ -27,19 +27,105 @@ module PlaceOS::Api inst2.save! inst2.persisted?.should be_true - refresh_elastic(Model::TriggerInstance.table_name) - params = HTTP::Params.encode({"as_of" => (inst1.updated_at.try &.to_unix).to_s}) path = "#{path}?#{params}" - correct_response = until_expected("GET", path, Spec::Authentication.headers) do |response| - results = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - contains_correct = results.any?(inst1.id) - contains_incorrect = results.any?(inst2.id) - !results.empty? && contains_correct && !contains_incorrect - end + result = client.get(path: path, headers: Spec::Authentication.headers) + result.status_code.should eq 200 + + results = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + results.should contain(inst1.id) + results.should_not contain(inst2.id) + end + + it "trigger_id" do + sys = Model::Generator.control_system.save! + base = SystemTriggers.base_route.gsub(/:sys_id/, sys.id) + + trigger1 = Model::Generator.trigger.save! + trigger2 = Model::Generator.trigger.save! + inst1 = Model::Generator.trigger_instance(trigger1, control_system: sys).save! + inst2 = Model::Generator.trigger_instance(trigger2, control_system: sys).save! + + params = HTTP::Params.encode({"trigger_id" => trigger1.id.as(String)}) + result = client.get(path: "#{base}?#{params}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 - correct_response.should be_true + results = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + results.should contain(inst1.id) + results.should_not contain(inst2.id) + + {inst1, inst2, trigger1, trigger2, sys}.each &.destroy + end + + it "important and triggered filter only when true" do + sys = Model::Generator.control_system.save! + base = SystemTriggers.base_route.gsub(/:sys_id/, sys.id) + + # NOTE: set flags via update — `before_create :set_importance` + # overwrites `important` with the parent trigger's value on create + flagged = Model::Generator.trigger_instance(control_system: sys).save! + flagged.important = true + flagged.triggered = true + flagged.save! + + plain = Model::Generator.trigger_instance(control_system: sys).save! + + # no params => both returned + result = client.get(path: base, headers: Spec::Authentication.headers) + result.status_code.should eq 200 + results = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + results.should contain(flagged.id) + results.should contain(plain.id) + + # important=true => only the important instance + result = client.get(path: "#{base}?important=true", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + results = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + results.should contain(flagged.id) + results.should_not contain(plain.id) + + # triggered=true => only the triggered instance + result = client.get(path: "#{base}?triggered=true", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + results = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + results.should contain(flagged.id) + results.should_not contain(plain.id) + + {flagged, plain, sys}.each &.destroy + end + + # Pins the PPT-2644 fix: under Elasticsearch the `q` param on this + # route was a silent no-op (the has_parent `should` clause was never + # required as minimum_should_match was unset). `q` now searches the + # parent trigger's text. + it "q searches by parent trigger name" do + sys = Model::Generator.control_system.save! + base = SystemTriggers.base_route.gsub(/:sys_id/, sys.id) + + trigger = Model::Generator.trigger + trigger.name = "Motion Detected" + trigger.save! + inst = Model::Generator.trigger_instance(trigger, control_system: sys).save! + + other_trigger = Model::Generator.trigger + other_trigger.name = "Door Held Open" + other_trigger.save! + other_inst = Model::Generator.trigger_instance(other_trigger, control_system: sys).save! + + # q matching the parent trigger's name returns its instance only + result = client.get(path: "#{base}?q=motion", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + results = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + results.should contain(inst.id) + results.should_not contain(other_inst.id) + + # q matching no trigger returns no instances + result = client.get(path: "#{base}?q=nonsensequery", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + Array(Hash(String, JSON::Any)).from_json(result.body).should be_empty + + {inst, other_inst, trigger, other_trigger, sys}.each &.destroy end end end diff --git a/spec/controllers/systems_spec.cr b/spec/controllers/systems_spec.cr index 473e9a7d..14d20a54 100644 --- a/spec/controllers/systems_spec.cr +++ b/spec/controllers/systems_spec.cr @@ -87,18 +87,14 @@ module PlaceOS::Api systems.each &.save! expected_ids = expected_systems.compact_map(&.id) - total_ids = expected_ids.size params = HTTP::Params.encode({"zone_id" => zone_id}) path = "#{Systems.base_route}?#{params}" - refresh_elastic(Model::ControlSystem.table_name) - found = until_expected("GET", path, Spec::Authentication.headers) do |response| - returned_ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - (returned_ids | expected_ids).size == total_ids - end - - found.should be_true + result = client.get(path, headers: Spec::Authentication.headers) + result.status_code.should eq 200 + returned_ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + returned_ids.sort.should eq expected_ids.sort end it "non-admin / non-support user can list systems (baseline)" do @@ -116,12 +112,11 @@ module PlaceOS::Api _, headers = Spec::Authentication.authentication(sys_admin: false, support: false) - refresh_elastic(Model::ControlSystem.table_name) - found = until_expected("GET", Systems.base_route, headers) do |response| - response.success? && - Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s).includes?(mine.id.as(String)) - end - found.should be_true + result = client.get(Systems.base_route, headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(mine.id.as(String)) + ids.should contain(unrelated.id.as(String)) mine.destroy unrelated.destroy @@ -141,15 +136,12 @@ module PlaceOS::Api _, headers = Spec::Authentication.authentication(sys_admin: false, support: false) - refresh_elastic(Model::ControlSystem.table_name) path = "#{Systems.base_route}?#{HTTP::Params.encode({"zone_id" => zone_id})}" - found = until_expected("GET", path, headers) do |response| - response.success? && begin - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - ids.includes?(mine.id.as(String)) && !ids.includes?(other.id.as(String)) - end - end - found.should be_true + result = client.get(path, headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(mine.id.as(String)) + ids.should_not contain(other.id.as(String)) mine.destroy other.destroy @@ -174,15 +166,12 @@ module PlaceOS::Api in_anchor.save! unrelated = Model::Generator.control_system.save! - refresh_elastic(Model::ControlSystem.table_name) path = "#{Systems.base_route}?#{HTTP::Params.encode({"group_id" => group.id.to_s})}" - found = until_expected("GET", path, headers) do |response| - response.success? && begin - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - ids.includes?(in_anchor.id.as(String)) && !ids.includes?(unrelated.id.as(String)) - end - end - found.should be_true + result = client.get(path, headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(in_anchor.id.as(String)) + ids.should_not contain(unrelated.id.as(String)) in_anchor.destroy unrelated.destroy @@ -224,16 +213,12 @@ module PlaceOS::Api in_child.save! out_of_scope = Model::Generator.control_system.save! - sleep 1.second - refresh_elastic(Model::ControlSystem.table_name) path = "#{Systems.base_route}?#{HTTP::Params.encode({"subsystem" => "signage"})}" - found = until_expected("GET", path, headers) do |response| - response.success? && begin - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - ids.includes?(in_child.id.as(String)) && !ids.includes?(out_of_scope.id.as(String)) - end - end - found.should be_true + result = client.get(path, headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(in_child.id.as(String)) + ids.should_not contain(out_of_scope.id.as(String)) in_child.destroy out_of_scope.destroy @@ -249,7 +234,6 @@ module PlaceOS::Api existing = Model::Generator.control_system.save! - refresh_elastic(Model::ControlSystem.table_name) path = "#{Systems.base_route}?#{HTTP::Params.encode({"subsystem" => "signage"})}" result = client.get(path, headers: headers) result.status_code.should eq 200 @@ -291,22 +275,17 @@ module PlaceOS::Api group_only.zones = [group_zone.id.as(String)] group_only.save! - sleep 1.second - refresh_elastic(Model::ControlSystem.table_name) params = HTTP::Params.encode({ "zone_id" => tag_zone.id.as(String), "group_id" => group.id.to_s, }) path = "#{Systems.base_route}?#{params}" - found = until_expected("GET", path, headers) do |response| - response.success? && begin - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - ids.includes?(both.id.as(String)) && - !ids.includes?(tag_only.id.as(String)) && - !ids.includes?(group_only.id.as(String)) - end - end - found.should be_true + result = client.get(path, headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(both.id.as(String)) + ids.should_not contain(tag_only.id.as(String)) + ids.should_not contain(group_only.id.as(String)) both.destroy tag_only.destroy @@ -332,16 +311,12 @@ module PlaceOS::Api a_only.zones = [a.id.as(String)] a_only.save! - sleep 1.second - refresh_elastic(Model::ControlSystem.table_name) path = "#{Systems.base_route}?#{HTTP::Params.encode({"zone_id" => "#{a.id},#{b.id}"})}" - found = until_expected("GET", path, Spec::Authentication.headers) do |response| - response.success? && begin - ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - ids.includes?(both.id.as(String)) && !ids.includes?(a_only.id.as(String)) - end - end - found.should be_true + result = client.get(path, headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(both.id.as(String)) + ids.should_not contain(a_only.id.as(String)) both.destroy a_only.destroy @@ -349,32 +324,94 @@ module PlaceOS::Api b.destroy end - it "email filters systems by email" do + it "email filters systems by email (exact, case-insensitive)" do + # NOTE: the Elasticsearch version of this filter was a no-op on + # its own (optional `should` clause) and the old spec passed on + # empty results. It is now a strict filter — pin the real + # inclusion/exclusion behaviour. Model::ControlSystem.clear - num_systems = 5 - systems = Array.new(size: num_systems) do - Model::Generator.control_system + matched = Array.new(2) do |i| + sys = Model::Generator.control_system + sys.email = PlaceOS::Model::Email.new("room#{i}-#{Random.rand(9999)}@example.com") + sys.save! end - # Add the zone to a subset of systems - expected_systems = systems.shuffle[0..2] - systems.each &.save! + unmatched = Model::Generator.control_system + unmatched.email = PlaceOS::Model::Email.new("other-#{Random.rand(9999)}@example.com") + unmatched.save! + no_email = Model::Generator.control_system.save! - expected_emails = expected_systems.compact_map(&.email) - expected_ids = expected_systems.compact_map(&.id) + expected_ids = matched.compact_map(&.id) + emails = matched.compact_map(&.email).map(&.to_s) - total_ids = expected_ids.size - params = HTTP::Params.encode({"email" => expected_emails.join(',')}) + params = HTTP::Params.encode({"email" => emails.join(',')}) path = "#{Systems.base_route}?#{params}" - found = until_expected("GET", path, Spec::Authentication.headers) do |response| - refresh_elastic(Model::ControlSystem.table_name) - returned_ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - (returned_ids | expected_ids).size == total_ids - end + result = client.get(path, headers: Spec::Authentication.headers) + result.status_code.should eq 200 + returned_ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + returned_ids.sort.should eq expected_ids.sort + returned_ids.should_not contain(unmatched.id.as(String)) + returned_ids.should_not contain(no_email.id.as(String)) + + # matching is case-insensitive + params = HTTP::Params.encode({"email" => emails.first.upcase}) + result = client.get("#{Systems.base_route}?#{params}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + returned_ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + returned_ids.should eq [matched.first.id.as(String)] + end - found.should be_true + it "email ANDs with the group_id scope instead of widening it" do + # The Elasticsearch implementation merged email into the same + # optional OR group as the zone scope, so a system merely + # matching the email leaked into (widened) the authorization + # scope. email is now a strict AND filter — a matching email + # outside the scope must NOT be returned. + clear_group_tables + Model::ControlSystem.clear + + authority = Model::Authority.find_by_domain("localhost").not_nil! + user, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + + group = Model::Generator.group(authority: authority).save! + Model::Generator.group_user(user: user, group: group, permissions: Model::Permissions::Read).save! + + zone = Model::Generator.zone.save! + Model::Generator.group_zone(group: group, zone: zone, permissions: Model::Permissions::Read).save! + + in_scope_match = Model::Generator.control_system + in_scope_match.zones = [zone.id.as(String)] + in_scope_match.email = PlaceOS::Model::Email.new("target-#{Random.rand(9999)}@example.com") + in_scope_match.save! + + in_scope_other = Model::Generator.control_system + in_scope_other.zones = [zone.id.as(String)] + in_scope_other.email = PlaceOS::Model::Email.new("nomatch-#{Random.rand(9999)}@example.com") + in_scope_other.save! + + out_of_scope_match = Model::Generator.control_system + out_of_scope_match.email = PlaceOS::Model::Email.new("leaked-#{Random.rand(9999)}@example.com") + out_of_scope_match.save! + + # ask for both the in-scope and the out-of-scope email — only + # the in-scope system may come back + emails = [in_scope_match.email, out_of_scope_match.email].compact.join(',', &.to_s) + params = HTTP::Params.encode({ + "group_id" => group.id.to_s, + "email" => emails, + }) + + result = client.get("#{Systems.base_route}?#{params}", headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should eq [in_scope_match.id.as(String)] + + in_scope_match.destroy + in_scope_other.destroy + out_of_scope_match.destroy + zone.destroy end it "should return systems by email" do @@ -441,7 +478,11 @@ module PlaceOS::Api Model::ControlSystem.clear num_systems = 5 - mod = Model::Generator.module.save! + # Pin a non-logic role: Generator.module rolls a RANDOM driver role, + # and a logic module gets attached to its own home system — which + # then legitimately matches the module_id filter as an extra result. + service_driver = Model::Generator.driver(role: Model::Driver::Role::Service).save! + mod = Model::Generator.module(driver: service_driver).save! module_id = mod.id.as(String) systems = Array.new(size: num_systems) do @@ -454,20 +495,187 @@ module PlaceOS::Api sys.modules = [module_id] end systems.each &.save! - sleep 1.second expected_ids = expected_systems.compact_map(&.id) - total_ids = expected_ids.size params = HTTP::Params.encode({"module_id" => module_id}) path = "#{Systems.base_route}?#{params}" - found = until_expected("GET", path, Spec::Authentication.headers) do |response| - refresh_elastic(Model::ControlSystem.table_name) - returned_ids = Array(Hash(String, JSON::Any)).from_json(response.body).map(&.["id"].as_s) - (returned_ids | expected_ids).size == total_ids - end + result = client.get(path, headers: Spec::Authentication.headers) + result.status_code.should eq 200 + returned_ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + returned_ids.sort.should eq expected_ids.sort + end - found.should be_true + it "trigger_id filters systems to those with an instance of the trigger" do + # The Elasticsearch implementation combined a has_child clause + # with a document-type filter no document could satisfy, so + # `?trigger_id=` always returned an empty list — this pins the + # intended (fixed) behaviour. + Model::ControlSystem.clear + + trigger = Model::Generator.trigger.save! + with_trigger = Model::Generator.control_system.save! + without_trigger = Model::Generator.control_system.save! + instance = Model::Generator.trigger_instance(trigger, control_system: with_trigger).save! + + params = HTTP::Params.encode({"trigger_id" => trigger.id.as(String)}) + result = client.get("#{Systems.base_route}?#{params}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should eq [with_trigger.id.as(String)] + ids.should_not contain(without_trigger.id.as(String)) + + # an unused trigger matches no systems + other_trigger = Model::Generator.trigger.save! + params = HTTP::Params.encode({"trigger_id" => other_trigger.id.as(String)}) + result = client.get("#{Systems.base_route}?#{params}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + Array(Hash(String, JSON::Any)).from_json(result.body).should be_empty + + instance.destroy + trigger.destroy + other_trigger.destroy + end + + it "capacity returns systems with capacity equal or greater" do + Model::ControlSystem.clear + + small = Model::Generator.control_system + small.capacity = 2 + small.save! + + exact = Model::Generator.control_system + exact.capacity = 5 + exact.save! + + large = Model::Generator.control_system + large.capacity = 10 + large.save! + + result = client.get("#{Systems.base_route}?capacity=5", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.sort.should eq [exact.id.as(String), large.id.as(String)].sort + end + + it "bookable filters on both true and false" do + Model::ControlSystem.clear + + bookable_sys = Model::Generator.control_system + bookable_sys.bookable = true + bookable_sys.save! + + non_bookable = Model::Generator.control_system.save! + + result = client.get("#{Systems.base_route}?bookable=true", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should eq [bookable_sys.id.as(String)] + + result = client.get("#{Systems.base_route}?bookable=false", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should eq [non_bookable.id.as(String)] + end + + it "signage filters on both true and false" do + Model::ControlSystem.clear + + signage_sys = Model::Generator.control_system + signage_sys.signage = true + signage_sys.save! + + regular = Model::Generator.control_system.save! + + result = client.get("#{Systems.base_route}?signage=true", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should eq [signage_sys.id.as(String)] + + result = client.get("#{Systems.base_route}?signage=false", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should eq [regular.id.as(String)] + end + + it "features requires all of the requested features" do + Model::ControlSystem.clear + + full = Model::Generator.control_system + full.features = Set{"whiteboard", "vidconf", "display"} + full.save! + + partial = Model::Generator.control_system + partial.features = Set{"whiteboard"} + partial.save! + + none = Model::Generator.control_system.save! + + result = client.get("#{Systems.base_route}?features=whiteboard,vidconf", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should eq [full.id.as(String)] + + # a single feature matches every system that has it + result = client.get("#{Systems.base_route}?features=whiteboard", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.sort.should eq [full.id.as(String), partial.id.as(String)].sort + ids.should_not contain(none.id.as(String)) + end + + it "public only filters when true" do + Model::ControlSystem.clear + + public_sys = Model::Generator.control_system + public_sys.public = true + public_sys.save! + + private_sys = Model::Generator.control_system.save! + + result = client.get("#{Systems.base_route}?public=true", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should eq [public_sys.id.as(String)] + + # `?public=false` does NOT filter (parity with the previous + # behaviour) — both systems are returned + result = client.get("#{Systems.base_route}?public=false", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.sort.should eq [public_sys.id.as(String), private_sys.id.as(String)].sort + end + + it "q combines with zone_id filtering" do + Model::ControlSystem.clear + + zone = Model::Generator.zone.save! + zone_id = zone.id.as(String) + token = random_name + + match = Model::Generator.control_system + match.name = "#{token} one" + match.zones = [zone_id] + match.save! + + same_zone = Model::Generator.control_system + same_zone.zones = [zone_id] + same_zone.save! + + same_name = Model::Generator.control_system + same_name.name = "#{token} two" + same_name.save! + + params = HTTP::Params.encode({"q" => token, "zone_id" => zone_id}) + result = client.get("#{Systems.base_route}?#{params}", headers: Spec::Authentication.headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should eq [match.id.as(String)] + + match.destroy + same_zone.destroy + same_name.destroy + zone.destroy end end end diff --git a/spec/controllers/users_spec.cr b/spec/controllers/users_spec.cr index 12431d65..2e5f82b9 100644 --- a/spec/controllers/users_spec.cr +++ b/spec/controllers/users_spec.cr @@ -4,17 +4,20 @@ module PlaceOS::Api describe Users do Spec.test_404(Users.base_route, model_name: Model::User.table_name, headers: Spec::Authentication.headers) - describe "CRUD operations", tags: "crud" do + describe "index", tags: "search" do + Spec.test_base_index(klass: Model::User, controller_klass: Users) + it "query via email" do model = Model::Generator.user.save! model.persisted?.should be_true id = model.id.as(String) - params = HTTP::Params.encode({"q" => model.email.to_s, "fields" => "email,"}) + # NOTE: previously sent `fields=email,` for a field-scoped + # Elasticsearch phrase match — under PG full-text search the email + # is tokenized into the user's search_vector so a plain `q` works + params = HTTP::Params.encode({"q" => model.email.to_s}) path = "#{Users.base_route}?#{params}" - sleep 2.seconds - result = client.get( path: path, headers: Spec::Authentication.headers, @@ -26,6 +29,84 @@ module PlaceOS::Api response.first.id.should eq id end + it "excludes soft-deleted users unless include_deleted is passed" do + model = Model::Generator.user + name = random_name + model.name = name + model.save! + model.deleted = true + model.save! + + params = HTTP::Params.encode({"q" => name}) + result = client.get( + path: "#{Users.base_route}?#{params}", + headers: Spec::Authentication.headers, + ) + result.status_code.should eq 200 + result.headers["X-Total-Count"].should eq "0" + Array(JSON::Any).from_json(result.body).should be_empty + + params = HTTP::Params.encode({"q" => name, "include_deleted" => "true"}) + result = client.get( + path: "#{Users.base_route}?#{params}", + headers: Spec::Authentication.headers, + ) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)).from_json(result.body).map(&.["id"].as_s) + ids.should contain(model.id) + end + + it "filters by authority_id for admins" do + other_authority = Model::Generator.authority("other-#{random_name}.example.com").save! + + name = random_name + local_user = Model::Generator.user + local_user.name = name + local_user.save! + other_user = Model::Generator.user(other_authority) + other_user.name = name + other_user.save! + + params = HTTP::Params.encode({"q" => name, "authority_id" => other_authority.id.as(String)}) + result = client.get( + path: "#{Users.base_route}?#{params}", + headers: Spec::Authentication.headers, + ) + + result.status_code.should eq 200 + users = Array(Hash(String, JSON::Any)).from_json(result.body) + users.size.should eq 1 + users.first["id"].as_s.should eq other_user.id + users.first["authority_id"].as_s.should eq other_authority.id + end + + it "forces non-admin callers to their own authority (authority_id param ignored)" do + non_admin, headers = Spec::Authentication.authentication(sys_admin: false, support: false) + other_authority = Model::Generator.authority("other-#{random_name}.example.com").save! + + name = random_name + own_domain_user = Model::Generator.user + own_domain_user.name = name + own_domain_user.save! + foreign_user = Model::Generator.user(other_authority) + foreign_user.name = name + foreign_user.save! + + params = HTTP::Params.encode({"q" => name, "authority_id" => other_authority.id.as(String)}) + result = client.get( + path: "#{Users.base_route}?#{params}", + headers: headers, + ) + + result.status_code.should eq 200 + users = Array(Hash(String, JSON::Any)).from_json(result.body) + users.size.should eq 1 + users.first["id"].as_s.should eq own_domain_user.id + users.first["authority_id"].as_s.should eq non_admin.authority_id + end + end + + describe "CRUD operations", tags: "crud" do it "show" do model = Model::Generator.user.save! model.persisted?.should be_true diff --git a/spec/controllers/zones_spec.cr b/spec/controllers/zones_spec.cr index cebb3ceb..b6340753 100644 --- a/spec/controllers/zones_spec.cr +++ b/spec/controllers/zones_spec.cr @@ -16,9 +16,6 @@ module PlaceOS::Api child2.parent_id = parent.id child2.save! - sleep 1.second - refresh_elastic(Model::Zone.table_name) - params = HTTP::Params.encode({"parent_id" => parent.id.as(String)}) path = "#{Zones.base_route}?#{params}" result = client.get(path, headers: Spec::Authentication.headers) @@ -51,9 +48,6 @@ module PlaceOS::Api child3.parent_id = parent3.id child3.save! - sleep 1.second - refresh_elastic(Model::Zone.table_name) - # Query for children of parent1 and parent2 (should not include child3) parent_ids = "#{parent1.id},#{parent2.id}" params = HTTP::Params.encode({"parent_id" => parent_ids}) @@ -83,9 +77,6 @@ module PlaceOS::Api child.parent_id = root1.id child.save! - sleep 1.second - refresh_elastic(Model::Zone.table_name) - params = HTTP::Params.encode({"parent_id" => "root"}) path = "#{Zones.base_route}?#{params}" result = client.get(path, headers: Spec::Authentication.headers) @@ -114,9 +105,6 @@ module PlaceOS::Api child.parent_id = nil_root.id child.save! - sleep 1.second - refresh_elastic(Model::Zone.table_name) - params = HTTP::Params.encode({"parent_id" => "root"}) path = "#{Zones.base_route}?#{params}" result = client.get(path, headers: Spec::Authentication.headers) @@ -147,9 +135,6 @@ module PlaceOS::Api unrelated = Model::Generator.zone.save! - sleep 1.second - refresh_elastic(Model::Zone.table_name) - params = HTTP::Params.encode({"parent_id" => "root,#{anchor.id}"}) path = "#{Zones.base_route}?#{params}" result = client.get(path, headers: Spec::Authentication.headers) @@ -186,9 +171,6 @@ module PlaceOS::Api grandchild.parent_id = child1.id grandchild.save! - sleep 1.second - refresh_elastic(Model::Zone.table_name) - params = HTTP::Params.encode({"parent_id" => parent.id.as(String), "include_children_count" => "true"}) path = "#{Zones.base_route}?#{params}" result = client.get(path, headers: Spec::Authentication.headers) @@ -222,9 +204,6 @@ module PlaceOS::Api children << child end - sleep 1.second - refresh_elastic(Model::Zone.table_name) - params = HTTP::Params.encode({ "parent_id" => parent.id.as(String), "include_children_count" => "true", @@ -262,9 +241,6 @@ module PlaceOS::Api group = Model::Generator.group(authority: authority).save! Model::Generator.group_zone(group: group, zone: anchor, permissions: Model::Permissions::Read).save! - sleep 1.second - refresh_elastic(Model::Zone.table_name) - params = HTTP::Params.encode({"group_id" => group.id.to_s}) result = client.get("#{Zones.base_route}?#{params}", headers: Spec::Authentication.headers) result.success?.should be_true diff --git a/spec/helper.cr b/spec/helper.cr index 9f6e92a0..08027b49 100644 --- a/spec/helper.cr +++ b/spec/helper.cr @@ -4,7 +4,6 @@ require "mutex" require "promise" require "random" require "pg-orm" -require "simple_retry" require "spec" require "./spec_helpers/*" @@ -92,42 +91,6 @@ def clear_tables {% end %} end -def until_expected(method, path, headers : HTTP::Headers, timeout : Time::Span = 3.seconds, &block : HTTP::Client::Response -> Bool) - client = ActionController::SpecHelper.client - channel = Channel(Bool).new - spawn do - before = Time.utc - begin - SimpleRetry.try_to(base_interval: 50.milliseconds, max_elapsed_time: 2.seconds, retry_on: Exception) do - result = client.exec(method: method, path: path, headers: headers) - - unless result.success? - puts "\nrequest failed with: #{result.status_code}" - puts result.body - end - - expected = block.call(result) - - raise Exception.new("retry") unless expected || channel.closed? - channel.send(true) if expected - end - rescue e - raise e unless e.message == "retry" - ensure - after = Time.utc - puts "took #{(after - before).milliseconds}ms" - end - end - - select - when found = channel.receive? - channel.close - !!found - when timeout(timeout) - false - end -end - def random_name UUID.random.to_s.split('-').first end @@ -149,34 +112,3 @@ def clear_group_tables PlaceOS::Model::DoorkeeperApplication, ].each(&.clear) end - -def refresh_elastic(index : String? = nil) - path = "/_refresh" - path = "/#{index}" + path unless index.nil? - Neuroplastic::Client.new.perform_request("POST", path) -end - -# Ensures the given fields are mapped as `keyword` on an existing ES index. -# The spec stack runs a pinned `search-ingest` image whose schema does not map -# enum-typed columns (its klass→ES-type table skips enums), so fields like -# `Alert#severity` aren't queryable in the test index even though the value is -# in `_source`. This mirrors the mapping that `es_type: "keyword"` on the model -# produces once search-ingest is rebuilt, letting controller ES filters be -# exercised locally. Adding fields to an existing mapping is non-destructive. -def ensure_keyword_mapping(index : String, fields : Enumerable(String)) - props = {} of String => Hash(String, String) - fields.each { |field| props[field] = {"type" => "keyword"} } - Neuroplastic::Client.new.perform_request("PUT", "/#{index}/_mapping", body: {properties: props}) -end - -# Removes all documents from an Elasticsearch index (keeping its mapping) and -# refreshes. `before_each` hooks clear the Postgres tables but not ES, so tests -# that assert exact counts on the *unscoped* index need a clean ES slate to be -# deterministic — otherwise stale docs from earlier examples inflate the count. -def clear_elastic(index : String) - Neuroplastic::Client.new.perform_request( - "POST", - "/#{index}/_delete_by_query?conflicts=proceed&refresh=true", - body: {query: {match_all: {} of String => String}} - ) -end diff --git a/spec/spec_helpers/authentication.cr b/spec/spec_helpers/authentication.cr index db56751d..237ad79a 100644 --- a/spec/spec_helpers/authentication.cr +++ b/spec/spec_helpers/authentication.cr @@ -80,20 +80,26 @@ module PlaceOS::Api::Spec::Authentication def self.org_zone zone = PlaceOS::Model::Zone.find?("zone-perm-org") - return zone if zone - - zone = Model::Generator.zone - zone.id = "zone-perm-org" - zone.tags = Set.new ["org"] - zone.save! + unless zone + zone = Model::Generator.zone + zone.id = "zone-perm-org" + zone.tags = Set.new ["org"] + zone.save! + end - metadata = Model::Generator.metadata("permissions", zone) - metadata.details = JSON.parse({ - admin: ["management"], - manage: ["concierge"], - }.to_json) + # The permissions doc is what grants the management/concierge groups their + # access — a spec that clears Metadata can remove it while the zone itself + # survives, so it must be re-ensured independently of zone creation. + unless Model::Metadata.where(parent_id: zone.id.as(String), name: "permissions").first? + metadata = Model::Generator.metadata("permissions", zone) + metadata.details = JSON.parse({ + admin: ["management"], + manage: ["concierge"], + }.to_json) + + metadata.save! + end - metadata.save! zone end end diff --git a/spec/spec_helpers/spec.cr b/spec/spec_helpers/spec.cr index 497303aa..e9282c41 100644 --- a/spec/spec_helpers/spec.cr +++ b/spec/spec_helpers/spec.cr @@ -12,7 +12,7 @@ module PlaceOS::Api::Spec end end - # Test search on name field + # Test search on name field (PG full-text search — synchronous, no index lag) macro test_base_index(klass, controller_klass) {% klass_name = klass.stringify.split("::").last.underscore %} @@ -22,21 +22,17 @@ module PlaceOS::Api::Spec name = random_name doc.name = name doc.save! - sleep 1.second - - refresh_elastic({{ klass }}.table_name) - doc.persisted?.should be_true + params = HTTP::Params.encode({"q" => name}) path = "#{{{controller_klass}}.base_route.rstrip('/')}?#{params}" - found = until_expected("GET", path, headers) do |response| - Array(Hash(String, JSON::Any)) - .from_json(response.body) - .map{|v| v.["id"].as_i64? || v.["id"].as_s?} - .any?(doc.id) - end - found.should be_true + result = client.get(path, headers: headers) + result.status_code.should eq 200 + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map { |v| v["id"].as_i64? || v["id"].as_s? } + ids.should contain(doc.id) end end diff --git a/spec/webrtc/rooms_spec.cr b/spec/webrtc/rooms_spec.cr new file mode 100644 index 00000000..d44a433f --- /dev/null +++ b/spec/webrtc/rooms_spec.cr @@ -0,0 +1,95 @@ +require "../helper" + +module PlaceOS::Api + describe WebRTC do + describe "GET /api/engine/v2/webrtc/rooms", tags: "search" do + # the route is unauthenticated: the domain's authority is resolved from + # the Host header, so requests carry no Authorization header at all + rooms_route = "/api/engine/v2/webrtc/rooms" + no_auth = HTTP::Headers{"Host" => "localhost"} + + it "lists only public systems, without authentication" do + # ensure the localhost authority exists (route resolves it from Host) + Spec::Authentication.headers + + public_sys = Model::Generator.control_system + public_sys.public = true + public_sys.save! + + private_sys = Model::Generator.control_system + private_sys.public = false + private_sys.save! + + result = client.get(path: rooms_route, headers: no_auth) + result.status_code.should eq 200 + + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(public_sys.id.as(String)) + ids.should_not contain(private_sys.id.as(String)) + result.headers["X-Total-Count"].to_i.should be >= 1 + end + + it "filters rooms by name with q" do + Spec::Authentication.headers + + target = random_name + match = Model::Generator.control_system + match.name = target + match.public = true + match.save! + + other = Model::Generator.control_system + other.public = true + other.save! + + params = HTTP::Params.encode({"q" => target}) + result = client.get(path: "#{rooms_route}?#{params}", headers: no_auth) + result.status_code.should eq 200 + + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(match.id.as(String)) + ids.should_not contain(other.id.as(String)) + end + + it "filters rooms to the authority's webrtc_zone" do + Spec::Authentication.headers + authority = Model::Authority.find_by_domain("localhost").not_nil! + + zone = Model::Generator.zone.save! + zone_id = zone.id.as(String) + + in_zone = Model::Generator.control_system + in_zone.public = true + in_zone.zones = [zone_id] + in_zone.save! + + out_of_zone = Model::Generator.control_system + out_of_zone.public = true + out_of_zone.save! + + begin + authority.internals_will_change! + authority.internals["webrtc_zone"] = JSON::Any.new(zone_id) + authority.save! + + result = client.get(path: rooms_route, headers: no_auth) + result.status_code.should eq 200 + + ids = Array(Hash(String, JSON::Any)) + .from_json(result.body) + .map(&.["id"].as_s) + ids.should contain(in_zone.id.as(String)) + ids.should_not contain(out_of_zone.id.as(String)) + ensure + authority.internals_will_change! + authority.internals.delete("webrtc_zone") + authority.save! + end + end + end + end +end diff --git a/spec/websocket/session_spec.cr b/spec/websocket/session_spec.cr index cda2cd99..476f179b 100644 --- a/spec/websocket/session_spec.cr +++ b/spec/websocket/session_spec.cr @@ -121,7 +121,7 @@ module PlaceOS::Api::WebSocket status_name = "nugget" id = rand(10).to_i64 - updates, _, _ = test_websocket_api(Systems.base_route, Spec::Authentication.headers) do |ws, control_system, mod, updates| + updates, _, _ = test_websocket_api(Systems.base_route, Spec::Authentication.headers) do |ws, control_system, mod, ws_updates| request = { id: id, system_id: control_system.id.as(String), @@ -130,7 +130,7 @@ module PlaceOS::Api::WebSocket command: Session::Request::Command::Debug, } ws.send Session::Request.new(**request).to_json - wait_for_updates(updates, 1) + wait_for_updates(ws_updates, 1) end # Check all messages received @@ -143,7 +143,7 @@ module PlaceOS::Api::WebSocket status_name = "nugget" id = rand(10).to_i64 - updates, _, _ = test_websocket_api(Systems.base_route, Spec::Authentication.headers) do |ws, control_system, mod, updates| + updates, _, _ = test_websocket_api(Systems.base_route, Spec::Authentication.headers) do |ws, control_system, mod, ws_updates| request = { id: id, system_id: control_system.id.as(String), @@ -152,7 +152,7 @@ module PlaceOS::Api::WebSocket command: Session::Request::Command::Ignore, } ws.send Session::Request.new(**request).to_json - wait_for_updates(updates, 1) + wait_for_updates(ws_updates, 1) end # Check all messages received diff --git a/src/placeos-rest-api/controllers/alert_dashboards.cr b/src/placeos-rest-api/controllers/alert_dashboards.cr index 0eb0b725..e07b2ee0 100644 --- a/src/placeos-rest-api/controllers/alert_dashboards.cr +++ b/src/placeos-rest-api/controllers/alert_dashboards.cr @@ -41,22 +41,17 @@ module PlaceOS::Api @[AC::Param::Info(description: "return dashboards for a specific authority", example: "authority-1234")] authority_id : String? = nil, ) : Array(::PlaceOS::Model::AlertDashboard) - elastic = ::PlaceOS::Model::AlertDashboard.elastic - query = elastic.query(search_params) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::AlertDashboard.all if authority_id - query.filter({ - "authority_id" => [authority_id], - }) + query = query.where(authority_id: authority_id) elsif !user_support? # Limit to current authority for non-support users - query.filter({ - "authority_id" => [authority.id.as(String)], - }) + query = query.where(authority_id: authority.id.as(String)) end - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + paginate_search(query, ::PlaceOS::Model::AlertDashboard.table_name) end # returns the details of an alert dashboard diff --git a/src/placeos-rest-api/controllers/alerts.cr b/src/placeos-rest-api/controllers/alerts.cr index 0c1b6b7b..16e674a9 100644 --- a/src/placeos-rest-api/controllers/alerts.cr +++ b/src/placeos-rest-api/controllers/alerts.cr @@ -43,42 +43,53 @@ module PlaceOS::Api @[AC::Param::Info(description: "filter by enabled status", example: "true")] enabled : Bool? = nil, ) : Array(::PlaceOS::Model::Alert) - elastic = ::PlaceOS::Model::Alert.elastic - query = elastic.query(search_params) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::Alert.all if adi = alert_dashboard_id - query.filter({ - "alert_dashboard_id" => [adi], - }) + query = query.where(alert_dashboard_id: adi) elsif !user_support? # Limit to current authority's dashboards for non-support users auth = current_authority.as(::PlaceOS::Model::Authority) dashboard_ids = ::PlaceOS::Model::AlertDashboard.where(authority_id: auth.id).select(:id).map(&.id.as(String)) - query.filter({ - "alert_dashboard_id" => dashboard_ids, - }) + if dashboard_ids.empty? + set_collection_headers(0, ::PlaceOS::Model::Alert.table_name) + return [] of ::PlaceOS::Model::Alert + end + # NOTE: the Elasticsearch version ANDed each dashboard id as a separate + # term filter, matching nothing whenever the authority had more than + # one dashboard — IN() is the intended semantics + query = query.where(alert_dashboard_id: dashboard_ids) end if sev = severity - query.filter({ - "severity" => [sev.downcase], - }) + # the PG enum column stores UPPERCASE labels while the API accepts and + # emits lowercase; an unrecognised value matches nothing (parity with + # the old Elasticsearch term filter, rather than a SQL cast error) + if parsed_severity = ::PlaceOS::Model::Alert::Severity.parse?(sev) + query = query.where("severity = ?::alert_severity", parsed_severity.to_s.upcase) + else + set_collection_headers(0, ::PlaceOS::Model::Alert.table_name) + return [] of ::PlaceOS::Model::Alert + end end if at = alert_type - query.filter({ - "alert_type" => [at.downcase], - }) + if parsed_type = ::PlaceOS::Model::Alert::AlertType.parse?(at) + query = query.where("alert_type = ?::alert_type", parsed_type.to_s.upcase) + else + set_collection_headers(0, ::PlaceOS::Model::Alert.table_name) + return [] of ::PlaceOS::Model::Alert + end end - if enb = enabled - query.filter({ - "enabled" => [enb], - }) + # NOTE: previously `if enb = enabled`, which silently ignored + # `enabled=false` — the filter now applies for both values + unless enabled.nil? + query = query.where(enabled: enabled) end - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + paginate_search(query, ::PlaceOS::Model::Alert.table_name) end # returns the details of an alert diff --git a/src/placeos-rest-api/controllers/api_keys.cr b/src/placeos-rest-api/controllers/api_keys.cr index b3f1a019..7ced7133 100644 --- a/src/placeos-rest-api/controllers/api_keys.cr +++ b/src/placeos-rest-api/controllers/api_keys.cr @@ -31,15 +31,14 @@ module PlaceOS::Api @[AC::Param::Info(description: "the ID of the domain to be listed", example: "auth-12345")] authority_id : String? = nil, ) : Array(::PlaceOS::Model::ApiKey::PublicResponse) - elastic = ::PlaceOS::Model::ApiKey.elastic - query = elastic.query(search_params) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::ApiKey.all if authority = authority_id - query.filter({"authority_id" => [authority]}) + query = query.where(authority_id: authority) end - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query).map(&.to_public_struct) + paginate_search(query, ::PlaceOS::Model::ApiKey.table_name).map(&.to_public_struct) end # returns the requested API key details diff --git a/src/placeos-rest-api/controllers/application.cr b/src/placeos-rest-api/controllers/application.cr index a926a99b..84560c07 100644 --- a/src/placeos-rest-api/controllers/application.cr +++ b/src/placeos-rest-api/controllers/application.cr @@ -38,9 +38,6 @@ module PlaceOS::Api # Helpers for defining scope checks on controller actions include Utils::Scopes - # Default sort for elasticsearch - NAME_SORT_ASC = {"name.keyword" => {order: :asc}} - # for converting comma seperated lists # i.e. `"id-1,id-2,id-3"` struct ConvertStringArray @@ -49,28 +46,6 @@ module PlaceOS::Api end end - def paginate_results(elastic, query, route = base_route) - data = elastic.search(query) - range_start = query.offset - range_end = data[:results].size + range_start - total_items = data[:total] - item_type = elastic.elastic_index - response.headers["X-Total-Count"] = total_items.to_s - # response.headers["Accept-Ranges"] = item_type - response.headers["Content-Range"] = "#{item_type} #{range_start}-#{range_end}/#{total_items}" - - if range_end < total_items - query_params["offset"] = (range_end + 1).to_s - query_params["limit"] = query.limit.to_s - if ref = data[:ref] - query_params["ref"] = ref - end - response.headers["Link"] = %(<#{route}?#{query_params}>; rel="next") - end - - data[:results] - end - def set_collection_headers(size : Int32, content_type : String) response.headers["X-Total-Count"] = size.to_s response.headers["Content-Range"] = "#{content_type} 0-#{size - 1}/#{size}" @@ -89,8 +64,7 @@ module PlaceOS::Api # SQL-based pagination for models that aren't Elasticsearch-indexed. # Accepts any PgORM relation (`Model.where(...)` etc.) and returns # the requested page. Sets `X-Total-Count`, `Content-Range`, and - # `Link` response headers the same way `paginate_results` does for - # Elasticsearch queries, so clients can paginate uniformly. + # `Link` response headers so clients can paginate uniformly. def paginate_sql( query, type : String, @@ -108,7 +82,9 @@ module PlaceOS::Api response.headers["Content-Range"] = "#{type} #{offset}-#{range_end}/#{total}" if range_end < total - query_params["offset"] = (range_end + 1).to_s + # NOTE: previously `range_end + 1`, which silently skipped one row per + # page for clients following the Link header's offset + query_params["offset"] = range_end.to_s query_params["limit"] = limit.to_s response.headers["Link"] = %(<#{route}?#{query_params}>; rel="next") end @@ -116,19 +92,61 @@ module PlaceOS::Api results end + # The `q` param of the current request as a safe tsquery string, or nil + # when no text filter applies (empty / "*" / nothing searchable). + # See `Utils::TextSearch`. + def search_tsquery : String? + Utils::TextSearch.tsquery(search_params["q"]?.as?(String)) + end + + # `limit`/`offset` with the caps the Elasticsearch layer used to enforce + # (Neuroplastic::Query), hardened to never raise on unexpected values + def search_limit : Int32 + (search_params["limit"]?.as?(String).try(&.to_i?) || 100).clamp(0, 10_000) + end + + def search_offset : Int32 + (search_params["offset"]?.as?(String).try(&.to_i?) || 0).clamp(0, 1_000_000) + end + + # Splices an array into raw SQL as "ARRAY[?, ?, ...]::text[]". + # pg-orm's raw `where(sql, args)` binds each element of an Enumerable as a + # separate parameter, so a Crystal array cannot be passed as one bound + # array value — spell out a placeholder per element instead. + def sql_array(list : Array) : String + "ARRAY[#{list.join(", ") { "?" }}]::text[]" + end + + # Standard paginated index route body (PPT-2644, replacing Elasticsearch): + # applies the `q` param against the model's generated `search_vector` + # column, orders deterministically and emits the pagination headers. + # Routes with parent-child search or bespoke text handling apply + # `search_tsquery` themselves and call `paginate_sql` directly. + def paginate_search( + query, + type : String, + route : String = base_route, + order : String = "name, id", + ) + if tsq = search_tsquery + query = query.where("search_vector @@ to_tsquery('simple', ?)", tsq) + end + paginate_sql(query.order(order), type, limit: search_limit, offset: search_offset, route: route) + end + getter! search_params : Hash(String, String | Array(String)) @[AC::Route::Filter(:before_action, only: [:index], converters: {fields: ConvertStringArray})] def build_search_params( - @[AC::Param::Info(name: "q", description: "returns results based on a [simple query string](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html)")] + @[AC::Param::Info(name: "q", description: "filters results by the given text; words match as prefixes against the resource's searchable fields and all words must match")] query : String = "*", @[AC::Param::Info(description: "the maximum number of results to return", example: "10000")] limit : UInt32 = 100_u32, - @[AC::Param::Info(description: "deprecated, the starting offset of the result set. Used to implement pagination")] + @[AC::Param::Info(description: "the starting offset of the result set, used to implement pagination")] offset : UInt32 = 0_u32, - @[AC::Param::Info(description: "a token for accessing the next page of results, provided in the `Link` header")] + @[AC::Param::Info(description: "deprecated, ignored — pagination follows the `Link` header's offset")] ref : String? = nil, - @[AC::Param::Info(description: "(Optional, comma separated array of strings) Array of fields you wish to search. Accepts wildcard expresssions and boost relevance score for matches for particular field using a caret ^ operator.")] + @[AC::Param::Info(description: "deprecated, ignored — search covers the resource's indexed fields")] fields : Array(String) = [] of String, ) search_params = { diff --git a/src/placeos-rest-api/controllers/asset_categories.cr b/src/placeos-rest-api/controllers/asset_categories.cr index 3eabcf04..8520a708 100644 --- a/src/placeos-rest-api/controllers/asset_categories.cr +++ b/src/placeos-rest-api/controllers/asset_categories.cr @@ -49,16 +49,17 @@ module PlaceOS::Api example: "true")] hidden : Bool? = nil, ) : Array(::PlaceOS::Model::AssetCategory) - elastic = ::PlaceOS::Model::AssetCategory.elastic - query = elastic.query(search_params) - - if value = hidden - query.must({ - "hidden" => [value], - }) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::AssetCategory.all + + # NOTE:: the Elasticsearch implementation silently skipped this filter + # when `hidden=false` (Crystal falsy), contradicting the documented + # behaviour — both values now filter as described + unless hidden.nil? + query = query.where(hidden: hidden) end - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + + paginate_search(query, ::PlaceOS::Model::AssetCategory.table_name) end # show the selected asset category diff --git a/src/placeos-rest-api/controllers/asset_purchase_orders.cr b/src/placeos-rest-api/controllers/asset_purchase_orders.cr index 8fbbf1ed..3cb588c4 100644 --- a/src/placeos-rest-api/controllers/asset_purchase_orders.cr +++ b/src/placeos-rest-api/controllers/asset_purchase_orders.cr @@ -45,10 +45,14 @@ module PlaceOS::Api # list the asset purchase_orders @[AC::Route::GET("/")] def index : Array(::PlaceOS::Model::AssetPurchaseOrder) - elastic = ::PlaceOS::Model::AssetPurchaseOrder.elastic - query = elastic.query(search_params) - # query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + # PG full-text search (PPT-2644): q matches purchase_order_number and + # invoice_number. The table has no name column so order by PO number + # for a deterministic listing (Elasticsearch had no explicit sort here). + paginate_search( + ::PlaceOS::Model::AssetPurchaseOrder.all, + ::PlaceOS::Model::AssetPurchaseOrder.table_name, + order: "purchase_order_number, id", + ) end # show the selected asset purchase_order diff --git a/src/placeos-rest-api/controllers/assets.cr b/src/placeos-rest-api/controllers/assets.cr index 62b9e1b4..f35ab593 100644 --- a/src/placeos-rest-api/controllers/assets.cr +++ b/src/placeos-rest-api/controllers/assets.cr @@ -67,69 +67,65 @@ module PlaceOS::Api @[AC::Param::Info(description: "return assets which have the features provided", example: "sit-to-stand,whiteboard")] features : Array(String)? = nil, ) : Array(::PlaceOS::Model::Asset) - elastic = ::PlaceOS::Model::Asset.elastic - query = elastic.query(search_params) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::Asset.all if zone_id - query.must({ - "zone_id" => [zone_id], - }) + query = query.where(zone_id: zone_id) end - # Filter systems by the zones it's in + # asset must be in every one of the listed zones (parity with the + # Elasticsearch AND-term semantics) if zones && !zones.empty? - query.must({ - "zones" => zones, - }) + query = query.where("zones @> #{sql_array(zones)}", zones) end if type_id - query.must({ - "asset_type_id" => [type_id], - }) + query = query.where(asset_type_id: type_id) end if order_id - query.must({ - "purchase_order_id" => [order_id], - }) + query = query.where(purchase_order_id: order_id) end if barcode - query.must({ - "barcode" => [barcode], - }) + query = query.where(barcode: barcode) end if serial_number - query.must({ - "serial_number" => [serial_number], - }) + query = query.where(serial_number: serial_number) end - if !bookable.nil? - query.must({ - "bookable" => [bookable], - }) + unless bookable.nil? + query = query.where(bookable: bookable) end - if !accessible.nil? - query.must({ - "accessible" => [accessible], - }) + unless accessible.nil? + query = query.where(accessible: accessible) end + # asset matches any of the listed features (parity with the + # Elasticsearch should + minimum_should_match(1) OR semantics) if features && !features.empty? - query.should({ - "features" => features, - }) - query.minimum_should_match(1) + query = query.where("features && #{sql_array(features)}", features) end - # query.has_parent(parent: ::PlaceOS::Model::AssetType, parent_index: ::PlaceOS::Model::AssetType.table_name) + # searching also matches text on the asset's type (name, brand, model + # number) — implements the previously commented-out has_parent(AssetType) + # query, mirroring modules-by-driver search + if tsq = search_tsquery + query = query.where( + "(search_vector @@ to_tsquery('simple', ?) OR EXISTS (SELECT 1 FROM asset_type at WHERE at.id = asset.asset_type_id AND at.search_vector @@ to_tsquery('simple', ?)))", + tsq, tsq + ) + end - query.sort({"id" => {order: :asc}}) - paginate_results(elastic, query) + paginate_sql( + query.order("name, id"), + ::PlaceOS::Model::Asset.table_name, + limit: search_limit, + offset: search_offset, + ) end # show the selected asset diff --git a/src/placeos-rest-api/controllers/authentications.cr b/src/placeos-rest-api/controllers/authentications.cr index e0d4450b..10862b25 100644 --- a/src/placeos-rest-api/controllers/authentications.cr +++ b/src/placeos-rest-api/controllers/authentications.cr @@ -34,17 +34,14 @@ module PlaceOS::Api @[AC::Param::Info(description: "return authentications that belong to the provided domain", example: "auth-12345")] authority_id : String? = nil, ) : Array(::PlaceOS::Model::{{auth_type.id}}Authentication) - elastic = ::PlaceOS::Model::{{auth_type.id}}Authentication.elastic - query = elastic.query(search_params) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::{{auth_type.id}}Authentication.all if authority = authority_id - query.filter({ - "authority_id" => [authority], - }) + query = query.where(authority_id: authority) end - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + paginate_search(query, ::PlaceOS::Model::{{auth_type.id}}Authentication.table_name) end # returns the details of a particular authentication diff --git a/src/placeos-rest-api/controllers/domains.cr b/src/placeos-rest-api/controllers/domains.cr index 61e62c3b..f730747e 100644 --- a/src/placeos-rest-api/controllers/domains.cr +++ b/src/placeos-rest-api/controllers/domains.cr @@ -29,10 +29,8 @@ module PlaceOS::Api # list the domains @[AC::Route::GET("/")] def index : Array(::PlaceOS::Model::Authority) - elastic = ::PlaceOS::Model::Authority.elastic - query = elastic.query(search_params) - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + # PG full-text search (PPT-2644) + paginate_search(::PlaceOS::Model::Authority.all, ::PlaceOS::Model::Authority.table_name) end # skip authentication for the lookup diff --git a/src/placeos-rest-api/controllers/drivers.cr b/src/placeos-rest-api/controllers/drivers.cr index ed44c4f0..6e2e8756 100644 --- a/src/placeos-rest-api/controllers/drivers.cr +++ b/src/placeos-rest-api/controllers/drivers.cr @@ -53,24 +53,18 @@ module PlaceOS::Api @[AC::Param::Info(description: "list only drivers for which update is available", example: "true")] update_available : Bool? = nil, ) : Array(::PlaceOS::Model::Driver) - elastic = ::PlaceOS::Model::Driver.elastic - query = elastic.query(search_params) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::Driver.all if role - query.filter({ - "role" => [role.to_i], - }) + query = query.where(role: role.to_i) end if update_available - query.filter({ - "update_available" => [update_available.as(Bool)], - }) + query = query.where(update_available: true) end - query.search_field "name" - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + paginate_search(query, ::PlaceOS::Model::Driver.table_name) end # view the details of a driver diff --git a/src/placeos-rest-api/controllers/edges.cr b/src/placeos-rest-api/controllers/edges.cr index 23997fea..867e4b67 100644 --- a/src/placeos-rest-api/controllers/edges.cr +++ b/src/placeos-rest-api/controllers/edges.cr @@ -68,10 +68,8 @@ module PlaceOS::Api # an edge can be thought of as a location and each edge location can have multiple nodes servicing it @[AC::Route::GET("/")] def index : Array(::PlaceOS::Model::Edge) - elastic = ::PlaceOS::Model::Edge.elastic - query = elastic.query(search_params) - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + # PG full-text search (PPT-2644) + paginate_search(::PlaceOS::Model::Edge.all, ::PlaceOS::Model::Edge.table_name) end # return the details of an edge location diff --git a/src/placeos-rest-api/controllers/modules.cr b/src/placeos-rest-api/controllers/modules.cr index dd7e255d..06edc17c 100644 --- a/src/placeos-rest-api/controllers/modules.cr +++ b/src/placeos-rest-api/controllers/modules.cr @@ -168,10 +168,8 @@ module PlaceOS::Api return results end - # we use Elasticsearch - elastic = ::PlaceOS::Model::Module.elastic - query = elastic.query(search_params) - query.minimum_should_match(1) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::Module.all # TODO:: we can remove this once there is a tenant_id field on modules # which will make this much simpler to filter @@ -200,34 +198,51 @@ module PlaceOS::Api conn.query_one(sql_query, args: scope_zones.map(&.as(PgORM::Value)), &.read(Array(String)?)) end || [] of String - query.must({ - "id" => module_ids, - }) + if module_ids.empty? + set_collection_headers(0, ::PlaceOS::Model::Module.table_name) + return [] of ::PlaceOS::Model::Module + end + + # NOTE: the Elasticsearch version ANDed each id as a separate term + # filter, returning nothing whenever a scoped user could access more + # than one module — this is the intended IN() semantics + query = query.where(id: module_ids) end if no_logic - query.must_not({"role" => [Model::Driver::Role::Logic.to_i]}) + query = query.where("role != ?", Model::Driver::Role::Logic.to_i) end if driver_id - query.filter({"driver_id" => [driver_id]}) + query = query.where(driver_id: driver_id) end unless running.nil? - query.should({"running" => [running]}) + query = query.where(running: running) end if as_of - query.range({ - "updated_at" => { - :lte => as_of, - }, - }) + # as_of is epoch seconds; compare at second granularity (the ES + # pipeline stored epoch-second integers, so lte was second-precise — + # a naive <= would exclude rows with sub-second timestamps) + query = query.where("updated_at < ?", Time.unix(as_of + 1)) end - query.has_parent(parent: ::PlaceOS::Model::Driver, parent_index: ::PlaceOS::Model::Driver.table_name) + # searching also matches text on the parent driver — parity with the + # Elasticsearch has_parent(Driver) query (search modules by driver name) + if tsq = search_tsquery + query = query.where( + "(search_vector @@ to_tsquery('simple', ?) OR EXISTS (SELECT 1 FROM driver d WHERE d.id = mod.driver_id AND d.search_vector @@ to_tsquery('simple', ?)))", + tsq, tsq + ) + end - search_results = paginate_results(elastic, query) + search_results = paginate_sql( + query.order("custom_name, name, id"), + ::PlaceOS::Model::Module.table_name, + limit: search_limit, + offset: search_offset, + ) # Include subset of association data with results # avoid n+1 requests diff --git a/src/placeos-rest-api/controllers/oauth_applications.cr b/src/placeos-rest-api/controllers/oauth_applications.cr index 902149db..a6d65a5d 100644 --- a/src/placeos-rest-api/controllers/oauth_applications.cr +++ b/src/placeos-rest-api/controllers/oauth_applications.cr @@ -32,41 +32,39 @@ module PlaceOS::Api @[AC::Param::Info(description: "the ID of the domain to be listed; ignored for non-admin/support callers (forced to their own authority)", example: "auth-12345")] authority_id : String? = nil, ) : Array(::PlaceOS::Model::DoorkeeperApplication) - elastic = ::PlaceOS::Model::DoorkeeperApplication.elastic - query = elastic.query(search_params) - query.sort(NAME_SORT_ASC) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::DoorkeeperApplication.all if user_support? # admin/support: optional authority_id filter, no subsystem gating. if authority = authority_id - query.must({ - "owner_id" => [authority], - }) + query = query.where(owner_id: authority) end else # Regular user: force the authority to the caller's own. own_authority = current_authority.as(::PlaceOS::Model::Authority).id.as(String) - query.must({ - "owner_id" => [own_authority], - }) + query = query.where(owner_id: own_authority) - # Subsystem gating: a regular user always sees the "common" - # apps (those with no subsystems set), plus any app tagged - # with one of their accessible subsystems. Both clauses go - # into a single `should` with `minimum_should_match(1)`. - # `nil` in the values list expands to a `must_not exists` - # term filter — that's how neuroplastic encodes "this field - # is missing/empty" inside a should clause. - accessible_subsystems = Array(String?).new - current_user.subsystem_access.each { |s| accessible_subsystems << s } - accessible_subsystems << nil - query.should({ - "subsystems" => accessible_subsystems, - }) - query.minimum_should_match(1) + # Subsystem gating: a regular user always sees the "common" apps + # (those with no subsystems tagged), plus any app tagged with one + # of their accessible subsystems. Elasticsearch encoded "common" + # as a missing field; in PG the column is a NOT NULL array + # defaulting to '{}', so common = empty (NULL kept for safety). + accessible = current_user.subsystem_access + if accessible.empty? + query = query.where("(subsystems IS NULL OR cardinality(subsystems) = ?)", 0) + else + # pg-orm raw `where` binds Enumerable elements individually, so + # the array literal is spliced element-wise + placeholders = accessible.join(", ") { "?" } + query = query.where( + "(subsystems && ARRAY[#{placeholders}]::text[] OR subsystems IS NULL OR cardinality(subsystems) = 0)", + accessible + ) + end end - paginate_results(elastic, query) + paginate_search(query, ::PlaceOS::Model::DoorkeeperApplication.table_name) end # show the details of the applications diff --git a/src/placeos-rest-api/controllers/pending_mails.cr b/src/placeos-rest-api/controllers/pending_mails.cr index 6a060aac..dc4c849a 100644 --- a/src/placeos-rest-api/controllers/pending_mails.cr +++ b/src/placeos-rest-api/controllers/pending_mails.cr @@ -63,7 +63,7 @@ module PlaceOS::Api ############################################################################################### - # list queued/processed mail, filtered via Elasticsearch + # list queued/processed mail @[AC::Route::GET("/", converters: {zones: ConvertStringArray})] def index( @[AC::Param::Info(description: "only mail whose zones are anchored to this group; non-support callers need Read on the group", example: "group-uuid")] @@ -91,16 +91,16 @@ module PlaceOS::Api @[AC::Param::Info(description: "mail scheduled to send at or after this time")] send_at_after : Time? = nil, ) : Array(::PlaceOS::Model::PendingMail) - elastic = ::PlaceOS::Model::PendingMail.elastic - query = elastic.query(search_params) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::PendingMail.all # Authority scoping: regular users are pinned to their own authority; # only support/admin may target another (or all) authorities. if requested = authority_id raise Error::Forbidden.new unless user_support? - query.filter({"authority_id" => [requested]}) + query = query.where(authority_id: requested) elsif !user_support? - query.filter({"authority_id" => [current_user.authority_id.as(String)]}) + query = query.where(authority_id: current_user.authority_id.as(String)) end # Group-anchor filter: resolve the group's zones first so we can @@ -115,73 +115,67 @@ module PlaceOS::Api set_collection_headers(0, ::PlaceOS::Model::PendingMail.table_name) return [] of ::PlaceOS::Model::PendingMail end - query.filter({"zones" => group_zone_ids}) + # AND-contains: the mail must reference every zone anchored to the + # group (parity with the ES term-per-value semantics) + query = query.where("zones @> #{sql_array(group_zone_ids)}", group_zone_ids) end if (filter_zones = zones) && !filter_zones.empty? - query.must({"zones" => filter_zones}) + # AND-contains, as above + query = query.where("zones @> #{sql_array(filter_zones)}", filter_zones) end - query.filter({"source_service" => [source_service]}) if source_service - query.filter({"source_reference" => [source_reference]}) if source_reference - query.filter({"user_id" => [user_id]}) if user_id + query = query.where(source_service: source_service) if source_service + query = query.where(source_reference: source_reference) if source_reference + query = query.where(user_id: user_id) if user_id - # Exclude rejected mail unless explicitly requested (nil ⇒ must_not exists). - query.filter({"rejected_at" => [nil] of String?}) unless include_rejected + # Exclude rejected mail unless explicitly requested. + query = query.where(rejected_at: nil) unless include_rejected # Neither sent nor rejected. - query.filter({"sent_at" => [nil] of String?, "rejected_at" => [nil] of String?}) if unsent_only + query = query.where(sent_at: nil, rejected_at: nil) if unsent_only - # Not expired: expiry missing OR in the future. Needs an OR group that - # mixes a missing-field check with a range, so it goes in raw. + # Not expired: expiry missing OR in the future. SQL expresses the + # OR-with-missing directly (the old ES query needed a raw bool for this). unless include_expired - query.raw_filter(JSON.parse({ - bool: { - should: [ - {bool: {must_not: {exists: {field: "expiry"}}}}, - {range: {expiry: {gte: Time.utc.to_rfc3339}}}, - ], - minimum_should_match: 1, - }, - }.to_json)) + query = query.where("(expiry IS NULL OR expiry >= ?)", Time.utc) end # Sent window. When include_rejected is set, the window also matches the - # rejected time (a mail is either sent or rejected) — an OR across two - # range fields, hence raw. + # rejected time (a mail is either sent or rejected). if sent_after || sent_before - window = time_window(sent_after, sent_before) + sent_sql, sent_args = time_window("sent_at", sent_after, sent_before) if include_rejected - query.raw_filter(JSON.parse({ - bool: { - should: [ - {range: {sent_at: window}}, - {range: {rejected_at: window}}, - ], - minimum_should_match: 1, - }, - }.to_json)) + rejected_sql, rejected_args = time_window("rejected_at", sent_after, sent_before) + query = query.where("(#{sent_sql} OR #{rejected_sql})", sent_args + rejected_args) else - query.raw_filter(JSON.parse({range: {sent_at: window}}.to_json)) + query = query.where(sent_sql, sent_args) end end if send_at = send_at_after - query.raw_filter(JSON.parse({range: {send_at: {gte: send_at.to_rfc3339}}}.to_json)) + query = query.where("send_at >= ?", send_at) end - query.sort({"created_at" => {order: :desc}}) - paginate_results(elastic, query) + # Feed-like route with no name column: newest first, id tie-break + paginate_search(query, ::PlaceOS::Model::PendingMail.table_name, order: "created_at DESC, id") end - # RFC3339 range bounds for ES. These Time fields (send_at/expiry/sent_at/ - # rejected_at) are mapped as ES `date` and serialized as ISO8601 strings — - # a bare integer in a range would be read as epoch-millis, so use strings. - private def time_window(after : Time?, before : Time?) : Hash(String, String) - window = {} of String => String - window["gte"] = after.to_rfc3339 if after - window["lte"] = before.to_rfc3339 if before - window + # SQL fragment + bound args for an inclusive time window over `column` + # (a trusted, controller-supplied name — never user input). Bounds are + # only emitted for the params provided, mirroring the old ES range clause. + private def time_window(column : String, after : Time?, before : Time?) : Tuple(String, Array(Time)) + parts = [] of String + args = [] of Time + if after + parts << "#{column} >= ?" + args << after + end + if before + parts << "#{column} <= ?" + args << before + end + {"(#{parts.join(" AND ")})", args} end # show the selected mail diff --git a/src/placeos-rest-api/controllers/repositories.cr b/src/placeos-rest-api/controllers/repositories.cr index a2ed209e..77282840 100644 --- a/src/placeos-rest-api/controllers/repositories.cr +++ b/src/placeos-rest-api/controllers/repositories.cr @@ -40,10 +40,8 @@ module PlaceOS::Api # lists the repositories added to the system @[AC::Route::GET("/")] def index : Array(::PlaceOS::Model::Repository) - elastic = ::PlaceOS::Model::Repository.elastic - query = elastic.query(search_params) - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + # PG full-text search (PPT-2644) + paginate_search(::PlaceOS::Model::Repository.all, ::PlaceOS::Model::Repository.table_name) end # returns the details of a saved repository diff --git a/src/placeos-rest-api/controllers/root.cr b/src/placeos-rest-api/controllers/root.cr index 404de365..55dd8583 100644 --- a/src/placeos-rest-api/controllers/root.cr +++ b/src/placeos-rest-api/controllers/root.cr @@ -1,7 +1,6 @@ require "./application" require "pg-orm" -require "search-ingest/client" require "placeos-frontend-loader/client" require "placeos-models/version" @@ -135,7 +134,7 @@ module PlaceOS::Api ) end - SERVICES = %w(core dispatch frontend_loader rest_api search_ingest source triggers) + SERVICES = %w(core dispatch frontend_loader rest_api source triggers) def self.construct_versions : Array(::PlaceOS::Model::Version) version_channel = Channel(::PlaceOS::Model::Version?).new @@ -164,10 +163,6 @@ module PlaceOS::Api FrontendLoader::Client.client(&.version) end - protected def self.search_ingest_version : ::PlaceOS::Model::Version - SearchIngest::Client.client(&.version) - end - protected def self.core_version : ::PlaceOS::Model::Version Api::Systems.core_for("version", &.version) end @@ -216,21 +211,21 @@ module PlaceOS::Api end end - # maps the database tables to indexes in elasticsearch + # DEPRECATED: search is served directly from PostgreSQL (PPT-2644); there + # is no external index to rebuild. Kept as a no-op so existing admin UIs + # and automation don't error. Remove in a future release. @[AC::Route::POST("/reindex")] def reindex( - @[AC::Param::Info(description: "backfill the database after re-indexing?", example: "true")] + @[AC::Param::Info(description: "deprecated, has no effect", example: "true")] backfill : Bool = false, ) : Nil - success = SearchIngest::Client.client &.reindex(backfill: backfill) - raise "reindex failed" unless success end - # pushes all the data from the database into elasticsearch + # DEPRECATED: search is served directly from PostgreSQL (PPT-2644); the + # database is always authoritative. Kept as a no-op so existing admin UIs + # and automation don't error. Remove in a future release. @[AC::Route::POST("/backfill")] - def backfill - success = SearchIngest::Client.client &.backfill - raise "backfill failed" unless success + def backfill : Nil end end end diff --git a/src/placeos-rest-api/controllers/schema.cr b/src/placeos-rest-api/controllers/schema.cr index 6fc85d6a..8bdfd86c 100644 --- a/src/placeos-rest-api/controllers/schema.cr +++ b/src/placeos-rest-api/controllers/schema.cr @@ -29,9 +29,10 @@ module PlaceOS::Api # schemas can be used to ensure metadata conforms to the desired state @[AC::Route::GET("/")] def index : Array(::PlaceOS::Model::JsonSchema) - elastic = ::PlaceOS::Model::JsonSchema.elastic - query = elastic.query(search_params) - paginate_results(elastic, query) + # PG full-text search (PPT-2644). The Elasticsearch version had no + # explicit sort (relevance-score order only); "name, id" keeps the + # listing deterministic. + paginate_search(::PlaceOS::Model::JsonSchema.all, ::PlaceOS::Model::JsonSchema.table_name) end # return the details of a schema diff --git a/src/placeos-rest-api/controllers/settings.cr b/src/placeos-rest-api/controllers/settings.cr index 32bb3686..117668e1 100644 --- a/src/placeos-rest-api/controllers/settings.cr +++ b/src/placeos-rest-api/controllers/settings.cr @@ -102,9 +102,12 @@ module PlaceOS::Api else raise Error::Forbidden.new unless user_support? - elastic = ::PlaceOS::Model::Settings.elastic - query = elastic.query(search_params) - paginate_results(elastic, query) + # PG full-text search (PPT-2644): `q` matches the settings keys (and + # id) — the settings body itself is deliberately not searchable. + # NOTE: parity with the Elasticsearch implementation — this branch + # intentionally does NOT decrypt results (no `decrypt_for!`), unlike + # the parent_id branch above. No name column — order by id. + paginate_search(::PlaceOS::Model::Settings.all, ::PlaceOS::Model::Settings.table_name, order: "id") end end diff --git a/src/placeos-rest-api/controllers/short_url.cr b/src/placeos-rest-api/controllers/short_url.cr index d53ea590..d2a5823d 100644 --- a/src/placeos-rest-api/controllers/short_url.cr +++ b/src/placeos-rest-api/controllers/short_url.cr @@ -58,14 +58,10 @@ module PlaceOS::Api # list the short URLs for this domain @[AC::Route::GET("/")] def index : Array(::PlaceOS::Model::Shortener) - elastic = ::PlaceOS::Model::Shortener.elastic - query = elastic.query(search_params) - query.filter({ - "authority_id" => [authority.id.as(String)], - }) - query.search_field "name" - query.sort({"created_at" => {order: :desc}}) - paginate_results(elastic, query) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::Shortener.all + .where(authority_id: authority.id.as(String)) + paginate_search(query, ::PlaceOS::Model::Shortener.table_name) end # return the details of the requested shortened URL diff --git a/src/placeos-rest-api/controllers/signage/plugins.cr b/src/placeos-rest-api/controllers/signage/plugins.cr index 8e03d818..8c339b58 100644 --- a/src/placeos-rest-api/controllers/signage/plugins.cr +++ b/src/placeos-rest-api/controllers/signage/plugins.cr @@ -63,25 +63,18 @@ module PlaceOS::Api ) : Array(::PlaceOS::Model::SignagePlugin) authority_id = authority.id.as(String) - elastic = ::PlaceOS::Model::SignagePlugin.elastic - query = elastic.query(search_params) - - if !enabled.nil? - query.must({ - "enabled" => [enabled], - }) + # PG full-text search (PPT-2644) + # this domain's plugins OR shared plugins (no authority) — SQL expresses + # the OR-with-NULL directly, unlike the Elasticsearch should-clause + # encoding this replaces + query = ::PlaceOS::Model::SignagePlugin.all + .where("(authority_id = ? OR authority_id IS NULL)", authority_id) + + unless enabled.nil? + query = query.where(enabled: enabled) end - query.should({ - "authority_id" => [authority_id, nil], - }) - query.minimum_should_match(1) - query.search_field "name" - query.sort(NAME_SORT_ASC) - - # ES can't express "field = X OR field IS NULL" in a single should clause, - # so we fetch authority-scoped results from ES and merge shared plugins from PG - paginate_results(elastic, query) + paginate_search(query, ::PlaceOS::Model::SignagePlugin.table_name) end # return the details of the requested signage plugin diff --git a/src/placeos-rest-api/controllers/system-triggers.cr b/src/placeos-rest-api/controllers/system-triggers.cr index 422e0cc2..32341885 100644 --- a/src/placeos-rest-api/controllers/system-triggers.cr +++ b/src/placeos-rest-api/controllers/system-triggers.cr @@ -73,40 +73,53 @@ module PlaceOS::Api @[AC::Param::Info(description: "return triggers updated before the time specified, unix epoch", example: "123456")] as_of : Int64? = nil, ) : Array(::PlaceOS::Model::TriggerInstance) - elastic = ::PlaceOS::Model::TriggerInstance.elastic - query = elastic.query(search_params) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::TriggerInstance.all # Filter by system ID - query.must({"control_system_id" => [control_system_id]}) + query = query.where(control_system_id: control_system_id) # Filter by trigger ID if trigger_id - query.filter({"trigger_id" => [trigger_id]}) + query = query.where(trigger_id: trigger_id) end # That occurred before a particular time if as_of - query.range({ - "updated_at" => { - :lte => as_of, - }, - }) + # as_of is epoch seconds; compare at second granularity (the ES + # pipeline stored epoch-second integers, so lte was second-precise — + # a naive <= would exclude rows with sub-second timestamps) + query = query.where("updated_at < ?", Time.unix(as_of + 1)) end # Filter by importance if important - query.filter({"important" => [true]}) + query = query.where(important: true) end # Filter by triggered if triggered - query.filter({"triggered" => [true]}) + query = query.where(triggered: true) end - # Include parent documents in the search - query.has_parent(parent: ::PlaceOS::Model::Trigger, parent_index: ::PlaceOS::Model::Trigger.table_name) + # A trigger instance has no searchable text of its own, so `q` matches + # the text of the parent trigger (replacing the Elasticsearch + # has_parent(Trigger) query — search a system's trigger instances by + # the trigger's name / description) + if tsq = search_tsquery + query = query.where( + %[EXISTS (SELECT 1 FROM "trigger" t WHERE t.id = trig.trigger_id AND t.search_vector @@ to_tsquery('simple', ?))], + tsq + ) + end - trigger_instances = paginate_results(elastic, query).map { |t| render_system_trigger(t, complete: complete) } + # no name column on this table — order by creation for determinism + trigger_instances = paginate_sql( + query.order("created_at, id"), + ::PlaceOS::Model::TriggerInstance.table_name, + limit: search_limit, + offset: search_offset, + ).map { |t| render_system_trigger(t, complete: complete) } trigger_instances end diff --git a/src/placeos-rest-api/controllers/systems.cr b/src/placeos-rest-api/controllers/systems.cr index 472b9c03..293666dd 100644 --- a/src/placeos-rest-api/controllers/systems.cr +++ b/src/placeos-rest-api/controllers/systems.cr @@ -208,23 +208,19 @@ module PlaceOS::Api @[AC::Param::Info(description: "return systems which are signage", example: "true")] signage : Bool? = nil, ) : Array(::PlaceOS::Model::ControlSystem) - elastic = ::PlaceOS::Model::ControlSystem.elastic - query = ::PlaceOS::Model::ControlSystem.elastic.query(search_params) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::ControlSystem.all # `zone_id` keeps its original AND semantics — a system must # contain *every* listed zone. The intended use is intersection # filters like "all meeting rooms on level 3", where each zone # tag narrows the result. if zone_id && !zone_id.empty? - query.must({ - "zones" => zone_id, - }) + query = query.where("zones @> #{sql_array(zone_id)}", zone_id) end # `group_id` and `subsystem` build an OR scope — a system needs - # to be in *any one* of the resolved zones. Their zone lists are - # combined into a single `should` clause with - # `minimum_should_match(1)` so the OR semantic is preserved. + # to be in *any one* of the resolved zones (array overlap). # When both are supplied the lists union; when neither is, no # scope clause is added. scope_zones = [] of String @@ -250,74 +246,66 @@ module PlaceOS::Api set_collection_headers(0, ::PlaceOS::Model::ControlSystem.table_name) return [] of ::PlaceOS::Model::ControlSystem end - query.should({ - "zones" => scope_zones.uniq!, - }) - query.minimum_should_match(1) + scope_zones.uniq! + query = query.where("zones && #{sql_array(scope_zones)}", scope_zones) end # Filter by module_id if module_id - query.must({ - "modules" => [module_id], - }) + query = query.where("? = ANY(modules)", module_id) end - # Filter by trigger_id + # Filter by trigger_id — systems that have a TriggerInstance of + # the given trigger. + # NOTE: the Elasticsearch version combined a has_child clause with + # a same-document type filter that no document could satisfy, so + # `?trigger_id=` always returned an empty list — this implements + # the intended semantics. if trigger_id - query.has_child(::PlaceOS::Model::TriggerInstance) - query.must({ - "trigger_id" => [trigger_id], - }) + query = query.where( + "EXISTS (SELECT 1 FROM trig WHERE trig.control_system_id = sys.id AND trig.trigger_id = ?)", + trigger_id + ) end - # Filter by features - if features - query.must({ - "features" => features, - }) + # Filter by features — the system must have all of those requested + if features && !features.empty? + query = query.where("features @> #{sql_array(features)}", features) end - # filter by capacity + # filter by capacity (equal or greater) if capacity - query.range({ - "capacity" => { - :gte => capacity, - }, - }) + query = query.where("capacity >= ?", capacity) end # filter by bookable unless bookable.nil? - query.must({ - "bookable" => [bookable], - }) + query = query.where(bookable: bookable) end - # filter by emails - if email - query.should({ - "email" => email, - }) + # filter by emails — exact (case-insensitive) match on any of the + # provided addresses. + # NOTE: the Elasticsearch version placed email in an optional + # `should` clause: on its own it filtered nothing and, combined + # with `group_id`/`subsystem`, it OR-ed into (widened) the zone + # scope. It is now a strict AND filter. + if email && !email.empty? + emails = email.map(&.strip.downcase) + query = query.where("LOWER(email) = ANY(#{sql_array(emails)})", emails) end - # filter by public + # filter by public (only filters when true, parity with the + # previous behaviour — `?public=false` returns everything) if public - query.must({ - "public" => [true], - }) + query = query.where(public: true) end # filter by signage unless signage.nil? - query.must({ - "signage" => [signage], - }) + query = query.where(signage: signage) end - query.search_field "name" - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + paginate_search(query, ::PlaceOS::Model::ControlSystem.table_name) end # Finds all the systems with the specified email address diff --git a/src/placeos-rest-api/controllers/triggers.cr b/src/placeos-rest-api/controllers/triggers.cr index 5427e86d..11ae1b5f 100644 --- a/src/placeos-rest-api/controllers/triggers.cr +++ b/src/placeos-rest-api/controllers/triggers.cr @@ -29,10 +29,8 @@ module PlaceOS::Api # returns the list of available triggers @[AC::Route::GET("/")] def index : Array(::PlaceOS::Model::Trigger) - elastic = ::PlaceOS::Model::Trigger.elastic - query = elastic.query(search_params) - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + # PG full-text search (PPT-2644) + paginate_search(::PlaceOS::Model::Trigger.all, ::PlaceOS::Model::Trigger.table_name) end # update so we can provide instance details diff --git a/src/placeos-rest-api/controllers/users.cr b/src/placeos-rest-api/controllers/users.cr index 46d3c694..1b4cf481 100644 --- a/src/placeos-rest-api/controllers/users.cr +++ b/src/placeos-rest-api/controllers/users.cr @@ -199,32 +199,35 @@ module PlaceOS::Api @[AC::Param::Info(description: "admin users can view other domains, ignored for other users", example: "auth-12345")] authority_id : String? = nil, ) : Array(UserDetails) - elastic = ::PlaceOS::Model::User.elastic - search_query = search_params - search_query["q"] = %("#{search_query["q"]}") if search_query["q"]?.to_s.is_email? - query = elastic.query(search_query) - query.sort(NAME_SORT_ASC) + # PG full-text search (PPT-2644) + # NOTE: email-shaped queries used to be quote-wrapped for an + # Elasticsearch phrase match; the user table's search_vector tokenizes + # email addresses on [@._] and also keeps the whole address as a + # lexeme, so `q` passes straight through `paginate_search` + query = ::PlaceOS::Model::User.all - query.must_not({"deleted" => [true]}) unless include_deleted + query = query.where(deleted: false) unless include_deleted if !user_admin? # regular users can only see their own domain - query.filter({"authority_id" => [current_user.authority_id.as(String)]}) + query = query.where(authority_id: current_user.authority_id.as(String)) elsif authority = authority_id - query.filter({"authority_id" => [authority]}) + query = query.where(authority_id: authority) end + results = paginate_search(query, ::PlaceOS::Model::User.table_name) + if user_admin? if include_metadata - paginate_results(elastic, query).map &.to_admin_metadata_struct.as(UserDetails) + results.map &.to_admin_metadata_struct.as(UserDetails) else - paginate_results(elastic, query).map &.to_admin_struct.as(UserDetails) + results.map &.to_admin_struct.as(UserDetails) end else if include_metadata - paginate_results(elastic, query).map &.to_public_metadata_struct.as(UserDetails) + results.map &.to_public_metadata_struct.as(UserDetails) else - paginate_results(elastic, query).map &.to_public_struct.as(UserDetails) + results.map &.to_public_struct.as(UserDetails) end end end diff --git a/src/placeos-rest-api/controllers/webrtc.cr b/src/placeos-rest-api/controllers/webrtc.cr index 28af3ecc..992e0de6 100644 --- a/src/placeos-rest-api/controllers/webrtc.cr +++ b/src/placeos-rest-api/controllers/webrtc.cr @@ -226,21 +226,14 @@ module PlaceOS::Api # this route provides a list of public chat rooms for the current domain @[AC::Route::GET("/rooms")] def index : Array(::PlaceOS::Model::ControlSystem) - elastic = ::PlaceOS::Model::ControlSystem.elastic - query = ::PlaceOS::Model::ControlSystem.elastic.query(search_params) - query.must({ - "public" => [true], - }) + # PG full-text search (PPT-2644) + query = ::PlaceOS::Model::ControlSystem.where(public: true) if zone_id = current_authority.not_nil!.internals["webrtc_zone"]?.try(&.as_s?) - query.must({ - "zones" => [zone_id], - }) + query = query.where("? = ANY(zones)", zone_id) end - query.search_field "name" - query.sort(NAME_SORT_ASC) - paginate_results(elastic, query) + paginate_search(query, ::PlaceOS::Model::ControlSystem.table_name) end ICE_CONFIG = {} of String => String diff --git a/src/placeos-rest-api/controllers/zones.cr b/src/placeos-rest-api/controllers/zones.cr index 3805292b..940936e9 100644 --- a/src/placeos-rest-api/controllers/zones.cr +++ b/src/placeos-rest-api/controllers/zones.cr @@ -159,104 +159,70 @@ module PlaceOS::Api include_children_count = true end - elastic = ::PlaceOS::Model::Zone.elastic - query = elastic.query(search_params) - query.sort(NAME_SORT_ASC) + # PG full-text search (PPT-2644) + # normalise an empty list to nil so the authorisation logic below reads + # the same for `?parent_id=` as for no param at all + parent_id = nil if parent_id.try(&.empty?) + + query = ::PlaceOS::Model::Zone.all if group_zone_ids - query.should({ - "id" => group_zone_ids, - }) - query.minimum_should_match(1) + query = query.where(id: group_zone_ids) end # Handle tree view queries if parent_id # Special case: "root" means zones with no parent if parent_id.includes?("root") - # Remove "root" and add any other parent_ids if present other_parents = parent_id.reject("root") if other_parents.empty? - # Only root zones: parent_id is missing OR equals "". - # Must go through `should` + `minimum_should_match(1)` — the - # neuroplastic DSL AND-s every entry in a `filter:` array, so - # using `filter` for [nil, ""] becomes (missing AND term="") - # which no document can satisfy. - parent_values = Array(String?).new(2) - parent_values << nil - parent_values << "" - query.should({ - "parent_id" => parent_values, - }) - query.minimum_should_match(1) + # Only root zones: parent_id is missing OR equals "" + query = query.where("(parent_id IS NULL OR parent_id = ?)", "") else - # Mix of root and specific parents: use OR logic - # Build array with nil and other parent IDs - parent_values = Array(String?).new(other_parents.size + 2) - parent_values << nil - parent_values << "" - parent_values.concat(other_parents) - query.should({ - "parent_id" => parent_values, - }) - query.minimum_should_match(1) + # Mix of root and specific parents (OR logic) + query = query.where("(parent_id IS NULL OR parent_id = '' OR parent_id = ANY(#{sql_array(other_parents)}))", other_parents) end else # Limit results to the children of these parents (OR logic) - query.should({ - "parent_id" => parent_id, - }) - query.minimum_should_match(1) + query = query.where(parent_id: parent_id) end end - # Limit results to zones containing the passed list of tags + # Limit results to zones containing ALL of the passed tags if (filter_tags = tags) && !filter_tags.empty? - query.must({ - "tags" => filter_tags, - }) + query = query.where("tags @> #{sql_array(filter_tags)}", filter_tags) elsif group_zone_ids.nil? - raise Error::Forbidden.new unless (parent_id && !parent_id.empty?) || user_support? - query.search_field "name" + raise Error::Forbidden.new unless parent_id || user_support? end - results = paginate_results(elastic, query) + results = paginate_search(query, ::PlaceOS::Model::Zone.table_name) # Add children count if requested if include_children_count - results = add_children_counts_from_es(results, elastic, query) + results = add_children_counts(results) end results end - # Helper to add children counts to zones using Elasticsearch aggregations - private def add_children_counts_from_es(zones : Array(::PlaceOS::Model::Zone), elastic, base_query) + # Helper to add children counts to zones (GROUP BY over parent_id, + # replacing the Elasticsearch terms aggregation) + private def add_children_counts(zones : Array(::PlaceOS::Model::Zone)) return zones if zones.empty? zone_ids = zones.map(&.id.as(String)) - # Query all zones whose parent_id matches any of our zone_ids, then aggregate by parent_id - agg_query = ::PlaceOS::Model::Zone.elastic.query({} of String => String) - agg_query.should({"parent_id" => zone_ids}) - agg_query.minimum_should_match(1) - agg_query.terms("children_by_parent", "parent_id", size: zone_ids.size) - - # Execute query with aggregation - data = ::PlaceOS::Model::Zone.elastic.search(agg_query) - - # Extract aggregation results - count_map = Hash(String, Int32).new - if aggs = data[:aggregations]? - if buckets = aggs.dig?("children_by_parent", "buckets").try(&.as_a?) - buckets.each do |bucket| - parent_id = bucket["key"].as_s - count_map[parent_id] = bucket["doc_count"].as_i - end + count_map = Hash(String, Int32).new(0) + PgORM::Database.connection do |db| + db.query_each( + "SELECT parent_id, COUNT(*)::int FROM zone WHERE parent_id = ANY($1) GROUP BY parent_id", + args: [zone_ids] + ) do |rs| + parent = rs.read(String) + count_map[parent] = rs.read(Int32) end end - # Assign counts to zones zones.each do |zone| zone.children_count = count_map[zone.id.as(String)]? || 0 end diff --git a/src/placeos-rest-api/utilities/text-search.cr b/src/placeos-rest-api/utilities/text-search.cr new file mode 100644 index 00000000..0e9508bc --- /dev/null +++ b/src/placeos-rest-api/utilities/text-search.cr @@ -0,0 +1,45 @@ +module PlaceOS::Api + # PPT-2644: translates the free-form `q` search param into a PostgreSQL + # tsquery matched against the generated `search_vector` columns + # (see placeos-models migration 20260806100500000). + # + # Guarantees: + # - never raises, and the output can never produce a tsquery syntax error: + # emitted tokens contain only letters/digits joined with `:*` and `&` + # - Elasticsearch-era query syntax that clients still send (field prefixes + # like `tags:(+level AND +building)`, boolean operators, quotes, wildcards) + # degrades gracefully into plain terms instead of erroring + # - every token is a prefix match (parity with the trailing `*` the old + # Neuroplastic layer appended to every query); tokens are ANDed, which is + # equal-or-stricter than the old OR and matches what autocomplete UIs + # expect (they intersect results client-side) + module Utils::TextSearch + extend self + + # ES-era boolean operators users/UIs may still include as literal words + OPERATOR_WORDS = {"and", "or", "not"} + + MAX_QUERY_CHARS = 512 + MAX_TOKENS = 16 + + # Builds the argument for `to_tsquery('simple', ?)` from user input, or + # returns `nil` when the input imposes no text filter (nil / blank / "*" / + # nothing searchable) — ES treated those as match-all. + def tsquery(q : String?) : String? + return nil if q.nil? + q = q[0, MAX_QUERY_CHARS] if q.size > MAX_QUERY_CHARS + + # drop `field:` prefixes (Backoffice's zone tag filter sends ES syntax + # like `tags:(+level AND +building)`) + text = q.gsub(/[\w.]+\s*:/, ' ') + + tokens = text + .split(/[^\p{L}\p{N}]+/, remove_empty: true) + .reject { |token| OPERATOR_WORDS.includes?(token.downcase) } + .first(MAX_TOKENS) + + return nil if tokens.empty? + tokens.join(" & ") { |token| "#{token}:*" } + end + end +end