@openneuro/server 4.38.3 → 4.39.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +1 -1
- package/package.json +6 -5
- package/src/cache/types.ts +1 -0
- package/src/datalad/__tests__/contributors.spec.ts +177 -0
- package/src/datalad/contributors.ts +153 -0
- package/src/datalad/dataset.ts +1 -1
- package/src/graphql/resolvers/__tests__/importRemoteDataset.spec.ts +2 -6
- package/src/graphql/resolvers/__tests__/user.spec.ts +18 -3
- package/src/graphql/resolvers/cache.ts +13 -9
- package/src/graphql/resolvers/datasetEvents.ts +470 -35
- package/src/graphql/resolvers/draft.ts +2 -0
- package/src/graphql/resolvers/mutation.ts +15 -1
- package/src/graphql/resolvers/snapshots.ts +10 -0
- package/src/graphql/resolvers/user.ts +182 -31
- package/src/graphql/schema.ts +98 -5
- package/src/libs/events.ts +5 -0
- package/src/models/datasetEvents.ts +126 -22
- package/src/models/user.ts +8 -0
- package/src/models/userNotificationStatus.ts +37 -0
- package/src/types/datacite.ts +97 -0
- package/src/utils/datacite-mapper.ts +21 -0
- package/src/utils/datacite-utils.ts +256 -0
- package/src/utils/orcid-utils.ts +17 -0
package/Dockerfile
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openneuro/server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.39.0-alpha.1",
|
|
4
4
|
"description": "Core service for the OpenNeuro platform.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"@elastic/elasticsearch": "8.13.1",
|
|
22
22
|
"@graphql-tools/schema": "^10.0.0",
|
|
23
23
|
"@keyv/redis": "^4.5.0",
|
|
24
|
-
"@openneuro/search": "^4.
|
|
24
|
+
"@openneuro/search": "^4.39.0-alpha.1",
|
|
25
25
|
"@sentry/node": "^8.25.0",
|
|
26
26
|
"@sentry/profiling-node": "^8.25.0",
|
|
27
27
|
"base64url": "^3.0.0",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"graphql-tools": "9.0.0",
|
|
39
39
|
"immutable": "^3.8.2",
|
|
40
40
|
"ioredis": "^5.6.1",
|
|
41
|
+
"js-yaml": "^4.1.0",
|
|
41
42
|
"jsdom": "24.0.0",
|
|
42
43
|
"jsonwebtoken": "^9.0.0",
|
|
43
44
|
"keyv": "^5.3.4",
|
|
@@ -75,6 +76,7 @@
|
|
|
75
76
|
"@types/express-serve-static-core": "^4.17.35",
|
|
76
77
|
"@types/ioredis": "^4.17.1",
|
|
77
78
|
"@types/ioredis-mock": "^8.2.2",
|
|
79
|
+
"@types/js-yaml": "^4",
|
|
78
80
|
"@types/node-mailjet": "^3",
|
|
79
81
|
"@types/semver": "^5",
|
|
80
82
|
"core-js": "^3.10.1",
|
|
@@ -82,11 +84,10 @@
|
|
|
82
84
|
"nodemon": "3.1.0",
|
|
83
85
|
"ts-node-dev": "1.1.6",
|
|
84
86
|
"tsc-watch": "^4.2.9",
|
|
85
|
-
"vitest": "2.
|
|
86
|
-
"vitest-fetch-mock": "0.3.0"
|
|
87
|
+
"vitest": "3.2.4"
|
|
87
88
|
},
|
|
88
89
|
"publishConfig": {
|
|
89
90
|
"access": "public"
|
|
90
91
|
},
|
|
91
|
-
"gitHead": "
|
|
92
|
+
"gitHead": "ba2f9a56450fcab27f02c27a05f7309f8d37affd"
|
|
92
93
|
}
|
package/src/cache/types.ts
CHANGED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import yaml from "js-yaml"
|
|
3
|
+
import * as Sentry from "@sentry/node"
|
|
4
|
+
import CacheItem from "../../cache/item"
|
|
5
|
+
import { fileUrl } from "../files"
|
|
6
|
+
import { datasetOrSnapshot } from "../../utils/datasetOrSnapshot"
|
|
7
|
+
import { contributors } from "../contributors"
|
|
8
|
+
|
|
9
|
+
vi.mock("../../libs/authentication/jwt", () => ({
|
|
10
|
+
sign: vi.fn(() => "mock_jwt_token"),
|
|
11
|
+
verify: vi.fn(() => ({ userId: "mock_user_id" })),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
vi.mock("js-yaml", () => ({
|
|
15
|
+
default: {
|
|
16
|
+
load: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
vi.mock("@sentry/node", () => ({
|
|
21
|
+
captureMessage: vi.fn(),
|
|
22
|
+
captureException: vi.fn(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.mock("../../cache/item")
|
|
26
|
+
vi.mock("../files")
|
|
27
|
+
vi.mock("../../utils/datasetOrSnapshot")
|
|
28
|
+
vi.mock("../libs/redis", () => ({
|
|
29
|
+
redis: vi.fn(),
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const mockYamlLoad = vi.mocked(yaml.load)
|
|
33
|
+
const mockSentryCaptureMessage = vi.mocked(Sentry.captureMessage)
|
|
34
|
+
const mockSentryCaptureException = vi.mocked(Sentry.captureException)
|
|
35
|
+
const mockFileUrl = vi.mocked(fileUrl)
|
|
36
|
+
const mockDatasetOrSnapshot = vi.mocked(datasetOrSnapshot)
|
|
37
|
+
|
|
38
|
+
const mockFetch = vi.fn()
|
|
39
|
+
global.fetch = mockFetch
|
|
40
|
+
|
|
41
|
+
const mockCacheItemGet = vi.fn()
|
|
42
|
+
vi.mocked(CacheItem).mockImplementation((_redis, _type, _key) => {
|
|
43
|
+
return {
|
|
44
|
+
get: mockCacheItemGet,
|
|
45
|
+
} as unknown as CacheItem
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe("contributors (core functionality)", () => {
|
|
49
|
+
const MOCK_DATASET_ID = "ds000001"
|
|
50
|
+
const MOCK_REVISION = "dce4b7b6653bcde9bdb7226a7c2b9499e77f2724"
|
|
51
|
+
const MOCK_REV_SHORT = MOCK_REVISION.substring(0, 7)
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks()
|
|
55
|
+
|
|
56
|
+
mockDatasetOrSnapshot.mockReturnValue({
|
|
57
|
+
datasetId: MOCK_DATASET_ID,
|
|
58
|
+
revision: MOCK_REVISION,
|
|
59
|
+
})
|
|
60
|
+
mockFileUrl.mockImplementation(
|
|
61
|
+
(datasetId, path, filename, revision) =>
|
|
62
|
+
`http://example.com/${datasetId}/${revision}/${filename}`,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
mockCacheItemGet.mockImplementation((fetcher) => fetcher())
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("should return empty array if both datacite file and dataset_description.json fail", async () => {
|
|
69
|
+
mockFetch.mockResolvedValueOnce({
|
|
70
|
+
status: 500,
|
|
71
|
+
headers: new Headers(),
|
|
72
|
+
text: () => Promise.resolve("Server Error"),
|
|
73
|
+
})
|
|
74
|
+
mockCacheItemGet.mockImplementationOnce((fetcher) =>
|
|
75
|
+
fetcher().catch(() => null)
|
|
76
|
+
)
|
|
77
|
+
const result = await contributors({
|
|
78
|
+
id: MOCK_DATASET_ID,
|
|
79
|
+
revision: MOCK_REVISION,
|
|
80
|
+
})
|
|
81
|
+
expect(result).toEqual([])
|
|
82
|
+
expect(mockSentryCaptureException).toHaveBeenCalledTimes(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("should return default empty array if no contributors array in datacite file or dataset_description.json (or wrong resourceTypeGeneral in datacite file)", async () => {
|
|
86
|
+
const dataciteYamlContent =
|
|
87
|
+
`data:\n attributes:\n types:\n resourceTypeGeneral: Software\n contributors: []`
|
|
88
|
+
const parsedDatacite = {
|
|
89
|
+
data: {
|
|
90
|
+
attributes: {
|
|
91
|
+
types: { resourceTypeGeneral: "Software" },
|
|
92
|
+
contributors: [],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
mockFetch.mockResolvedValueOnce({
|
|
98
|
+
status: 200,
|
|
99
|
+
headers: new Headers({ "Content-Type": "application/yaml" }),
|
|
100
|
+
text: () => Promise.resolve(dataciteYamlContent),
|
|
101
|
+
})
|
|
102
|
+
mockYamlLoad.mockReturnValueOnce(parsedDatacite)
|
|
103
|
+
const result = await contributors({
|
|
104
|
+
id: MOCK_DATASET_ID,
|
|
105
|
+
revision: MOCK_REVISION,
|
|
106
|
+
})
|
|
107
|
+
expect(result).toEqual([])
|
|
108
|
+
expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
|
|
109
|
+
`Datacite file for ${MOCK_DATASET_ID}:${MOCK_REV_SHORT} found but resourceTypeGeneral is 'Software', not 'Dataset'.`,
|
|
110
|
+
)
|
|
111
|
+
expect(mockSentryCaptureException).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("should return default empty array if datacite file is Dataset type but provides no contributors", async () => {
|
|
115
|
+
const dataciteYamlContent =
|
|
116
|
+
`data:\n attributes:\n types:\n resourceTypeGeneral: Dataset\n contributors: []`
|
|
117
|
+
const parsedDatacite = {
|
|
118
|
+
data: {
|
|
119
|
+
attributes: {
|
|
120
|
+
types: { resourceTypeGeneral: "Dataset" },
|
|
121
|
+
contributors: [],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
mockFetch.mockResolvedValueOnce({
|
|
127
|
+
status: 200,
|
|
128
|
+
headers: new Headers({ "Content-Type": "application/yaml" }),
|
|
129
|
+
text: () => Promise.resolve(dataciteYamlContent),
|
|
130
|
+
})
|
|
131
|
+
mockYamlLoad.mockReturnValueOnce(parsedDatacite)
|
|
132
|
+
const result = await contributors({
|
|
133
|
+
id: MOCK_DATASET_ID,
|
|
134
|
+
revision: MOCK_REVISION,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
expect(result).toEqual([])
|
|
138
|
+
expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
|
|
139
|
+
`Datacite file for ${MOCK_DATASET_ID}:${MOCK_REV_SHORT} is Dataset type but provided no contributors.`,
|
|
140
|
+
)
|
|
141
|
+
expect(mockSentryCaptureException).not.toHaveBeenCalled()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("should capture message if datacite file has unexpected content type but still parses", async () => {
|
|
145
|
+
const dataciteYamlContent =
|
|
146
|
+
`data:\n attributes:\n types:\n resourceTypeGeneral: Dataset\n contributors: []`
|
|
147
|
+
const parsedDatacite = {
|
|
148
|
+
data: {
|
|
149
|
+
attributes: {
|
|
150
|
+
types: { resourceTypeGeneral: "Dataset" },
|
|
151
|
+
contributors: [],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
contentType: "text/plain", // simulate unexpected content type
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
mockFetch.mockResolvedValueOnce({
|
|
158
|
+
status: 200,
|
|
159
|
+
headers: new Headers({ "Content-Type": "text/plain" }),
|
|
160
|
+
text: () => Promise.resolve(dataciteYamlContent),
|
|
161
|
+
})
|
|
162
|
+
mockYamlLoad.mockReturnValueOnce(parsedDatacite)
|
|
163
|
+
const result = await contributors({
|
|
164
|
+
id: MOCK_DATASET_ID,
|
|
165
|
+
revision: MOCK_REVISION,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
|
|
169
|
+
`Datacite file for ${MOCK_DATASET_ID}:${MOCK_REV_SHORT} served with unexpected Content-Type: text/plain. Attempting YAML parse anyway.`,
|
|
170
|
+
)
|
|
171
|
+
expect(mockSentryCaptureMessage).toHaveBeenCalledWith(
|
|
172
|
+
`Datacite file for ${MOCK_DATASET_ID}:${MOCK_REV_SHORT} is Dataset type but provided no contributors.`,
|
|
173
|
+
)
|
|
174
|
+
expect(mockSentryCaptureException).not.toHaveBeenCalled()
|
|
175
|
+
expect(result).toEqual([])
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import * as Sentry from "@sentry/node"
|
|
2
|
+
import CacheItem, { CacheType } from "../cache/item"
|
|
3
|
+
import { redis } from "../libs/redis"
|
|
4
|
+
import {
|
|
5
|
+
type DatasetOrSnapshot,
|
|
6
|
+
datasetOrSnapshot,
|
|
7
|
+
} from "../utils/datasetOrSnapshot"
|
|
8
|
+
import {
|
|
9
|
+
getDataciteYml,
|
|
10
|
+
normalizeRawContributors,
|
|
11
|
+
updateContributorsUtil,
|
|
12
|
+
} from "../utils/datacite-utils"
|
|
13
|
+
import type { Contributor, RawDataciteYml } from "../types/datacite"
|
|
14
|
+
import { description } from "./description"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* GraphQL resolver: fetch contributors for a dataset or snapshot
|
|
18
|
+
* Pure function: reads Datacite.yml or dataset_description.json and returns the list
|
|
19
|
+
*/
|
|
20
|
+
export const contributors = async (
|
|
21
|
+
obj: DatasetOrSnapshot,
|
|
22
|
+
): Promise<Contributor[]> => {
|
|
23
|
+
if (!obj) return []
|
|
24
|
+
|
|
25
|
+
const { datasetId, revision } = datasetOrSnapshot(obj)
|
|
26
|
+
if (!datasetId) return []
|
|
27
|
+
|
|
28
|
+
const revisionShort = revision ? revision.substring(0, 7) : "HEAD"
|
|
29
|
+
const dataciteCache = new CacheItem(redis, CacheType.dataciteYml, [
|
|
30
|
+
datasetId,
|
|
31
|
+
revisionShort,
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const dataciteData: RawDataciteYml & { contentType?: string } | null =
|
|
36
|
+
await dataciteCache.get(() => getDataciteYml(datasetId, revision))
|
|
37
|
+
|
|
38
|
+
if (!dataciteData) return []
|
|
39
|
+
|
|
40
|
+
// --- Capture unexpected content type ---
|
|
41
|
+
if (
|
|
42
|
+
dataciteData.contentType &&
|
|
43
|
+
dataciteData.contentType !== "application/yaml"
|
|
44
|
+
) {
|
|
45
|
+
Sentry.captureMessage(
|
|
46
|
+
`Datacite file for ${datasetId}:${revisionShort} served with unexpected Content-Type: ${dataciteData.contentType}. Attempting YAML parse anyway.`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const attributes = dataciteData.data?.attributes
|
|
51
|
+
const resourceType = attributes?.types?.resourceTypeGeneral
|
|
52
|
+
|
|
53
|
+
// --- Wrong resourceTypeGeneral ---
|
|
54
|
+
if (resourceType && resourceType !== "Dataset") {
|
|
55
|
+
Sentry.captureMessage(
|
|
56
|
+
`Datacite file for ${datasetId}:${revisionShort} found but resourceTypeGeneral is '${resourceType}', not 'Dataset'.`,
|
|
57
|
+
)
|
|
58
|
+
return []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Contributors from Datacite.yml ---
|
|
62
|
+
if (attributes?.contributors?.length) {
|
|
63
|
+
const normalized = await normalizeRawContributors(attributes.contributors)
|
|
64
|
+
return normalized
|
|
65
|
+
.map((c, index) => ({ ...c, order: c.order ?? index + 1 }))
|
|
66
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Dataset type but no contributors ---
|
|
70
|
+
if (resourceType === "Dataset") {
|
|
71
|
+
Sentry.captureMessage(
|
|
72
|
+
`Datacite file for ${datasetId}:${revisionShort} is Dataset type but provided no contributors.`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Fallback: dataset_description.json authors ---
|
|
77
|
+
const datasetDescription = await description(obj)
|
|
78
|
+
if (datasetDescription?.Authors?.length) {
|
|
79
|
+
return datasetDescription.Authors.map((
|
|
80
|
+
author: string,
|
|
81
|
+
index: number,
|
|
82
|
+
) => ({
|
|
83
|
+
name: author.trim(),
|
|
84
|
+
givenName: undefined,
|
|
85
|
+
familyName: undefined,
|
|
86
|
+
orcid: undefined,
|
|
87
|
+
contributorType: "Researcher",
|
|
88
|
+
order: index + 1,
|
|
89
|
+
userId: undefined,
|
|
90
|
+
}))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return []
|
|
94
|
+
} catch (err) {
|
|
95
|
+
Sentry.captureException(err)
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* GraphQL mutation resolver
|
|
102
|
+
*/
|
|
103
|
+
export interface UserInfo {
|
|
104
|
+
id?: string
|
|
105
|
+
_id?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface GraphQLContext {
|
|
109
|
+
userInfo: UserInfo | null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const updateContributors = async (
|
|
113
|
+
_parent: DatasetOrSnapshot,
|
|
114
|
+
args: { datasetId: string; newContributors: Contributor[] },
|
|
115
|
+
context: GraphQLContext,
|
|
116
|
+
) => {
|
|
117
|
+
const userId = context?.userInfo?.id || context?.userInfo?._id
|
|
118
|
+
if (!userId) {
|
|
119
|
+
return { success: false, dataset: null }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const contributorsToSave = args.newContributors.map((c, index) => ({
|
|
124
|
+
...c,
|
|
125
|
+
contributorType: c.contributorType || "Researcher",
|
|
126
|
+
order: c.order ?? index + 1,
|
|
127
|
+
}))
|
|
128
|
+
|
|
129
|
+
const result = await updateContributorsUtil(
|
|
130
|
+
args.datasetId,
|
|
131
|
+
contributorsToSave,
|
|
132
|
+
userId,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
success: true,
|
|
137
|
+
dataset: {
|
|
138
|
+
id: args.datasetId,
|
|
139
|
+
draft: {
|
|
140
|
+
id: args.datasetId,
|
|
141
|
+
contributors: contributorsToSave.sort((a, b) =>
|
|
142
|
+
(a.order ?? 0) - (b.order ?? 0)
|
|
143
|
+
),
|
|
144
|
+
files: result.draft.files || [],
|
|
145
|
+
modified: new Date().toISOString(),
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
Sentry.captureException(err)
|
|
151
|
+
return { success: false, dataset: null }
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/datalad/dataset.ts
CHANGED
|
@@ -124,7 +124,7 @@ export const deleteDataset = async (datasetId, user) => {
|
|
|
124
124
|
)
|
|
125
125
|
await request
|
|
126
126
|
.del(`${getDatasetWorker(datasetId)}/datasets/${datasetId}`)
|
|
127
|
-
await Dataset.deleteOne({ datasetId }).exec()
|
|
127
|
+
await Dataset.deleteOne({ id: datasetId }).exec()
|
|
128
128
|
await updateEvent(event)
|
|
129
129
|
return true
|
|
130
130
|
}
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import { vi } from "vitest"
|
|
2
2
|
import { allowedImportUrl, importRemoteDataset } from "../importRemoteDataset"
|
|
3
|
-
import createFetchMock from "vitest-fetch-mock"
|
|
4
3
|
|
|
5
4
|
vi.mock("ioredis")
|
|
6
5
|
vi.mock("../../../config")
|
|
7
6
|
vi.mock("../../permissions")
|
|
8
7
|
|
|
9
8
|
describe("importRemoteDataset mutation", () => {
|
|
10
|
-
it("given a user with access, it creates an import record for later processing", () => {
|
|
11
|
-
|
|
12
|
-
fetchMock.doMock()
|
|
13
|
-
fetchMock.mockOnce(JSON.stringify(true))
|
|
14
|
-
importRemoteDataset(
|
|
9
|
+
it("given a user with access, it creates an import record for later processing", async () => {
|
|
10
|
+
await importRemoteDataset(
|
|
15
11
|
{},
|
|
16
12
|
{ datasetId: "ds000000", url: "" },
|
|
17
13
|
{ user: "1234", userInfo: { admin: true } },
|
|
@@ -133,9 +133,24 @@ describe("user resolvers", () => {
|
|
|
133
133
|
})
|
|
134
134
|
|
|
135
135
|
describe("users()", () => {
|
|
136
|
-
it("
|
|
137
|
-
await
|
|
138
|
-
|
|
136
|
+
it("returns sanitized data for non-admin context", async () => {
|
|
137
|
+
const result = await users(null, {}, nonAdminContext)
|
|
138
|
+
|
|
139
|
+
// Should return all non-migrated users (same as admin)
|
|
140
|
+
expect(result.users.length).toBe(6)
|
|
141
|
+
expect(result.totalCount).toBe(6)
|
|
142
|
+
|
|
143
|
+
// Sensitive fields should be hidden
|
|
144
|
+
result.users.forEach((u) => {
|
|
145
|
+
expect(u.email).toBeNull()
|
|
146
|
+
expect(u.blocked).toBeNull()
|
|
147
|
+
expect(u.admin).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Non-sensitive fields should still be populated
|
|
151
|
+
const userIds = result.users.map((u) => u.id)
|
|
152
|
+
expect(userIds).toEqual(
|
|
153
|
+
expect.arrayContaining(["u1", "u2", "u3", "u4", "u6", "u7"]),
|
|
139
154
|
)
|
|
140
155
|
})
|
|
141
156
|
|
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import { redis } from "../../libs/redis.js"
|
|
2
|
-
import CacheItem from "../../cache/item"
|
|
3
|
-
import { CacheType } from "../../cache/types"
|
|
4
2
|
|
|
5
3
|
/**
|
|
6
|
-
* Clear
|
|
4
|
+
* Clear all cache entries for a given datasetId
|
|
7
5
|
*/
|
|
8
6
|
export async function cacheClear(
|
|
9
7
|
obj: Record<string, unknown>,
|
|
10
|
-
{ datasetId
|
|
8
|
+
{ datasetId }: { datasetId: string },
|
|
11
9
|
{ userInfo }: { userInfo: { admin: boolean } },
|
|
12
10
|
): Promise<boolean> {
|
|
13
11
|
// Check for admin and validate datasetId argument
|
|
14
12
|
if (userInfo?.admin && datasetId.length == 8 && datasetId.startsWith("ds")) {
|
|
15
|
-
const downloadCache = new CacheItem(redis, CacheType.snapshotDownload, [
|
|
16
|
-
datasetId,
|
|
17
|
-
tag,
|
|
18
|
-
])
|
|
19
13
|
try {
|
|
20
|
-
|
|
14
|
+
const stream = redis.scanStream({
|
|
15
|
+
// Scan for any keys that include the datasetId
|
|
16
|
+
match: `*${datasetId}*`,
|
|
17
|
+
})
|
|
18
|
+
const pipeline = redis.pipeline()
|
|
19
|
+
for await (const keys of stream) {
|
|
20
|
+
for (const key of keys) {
|
|
21
|
+
pipeline.del(key)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
await pipeline.exec()
|
|
21
25
|
return true
|
|
22
26
|
} catch (_err) {
|
|
23
27
|
return false
|