@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 CHANGED
@@ -4,7 +4,7 @@ FROM openneuro/node AS build
4
4
  WORKDIR /srv/packages/openneuro-server
5
5
  RUN yarn build
6
6
 
7
- FROM node:20.12.2-alpine
7
+ FROM node:22.20-alpine
8
8
 
9
9
  WORKDIR /srv
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openneuro/server",
3
- "version": "4.38.3",
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.38.3",
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.1.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": "a5808939dc6c528d0647a9bd2b70fee21531479f"
92
+ "gitHead": "ba2f9a56450fcab27f02c27a05f7309f8d37affd"
92
93
  }
@@ -15,4 +15,5 @@ export enum CacheType {
15
15
  draftRevision = "revision",
16
16
  brainInitiative = "brainInitiative",
17
17
  validation = "validation",
18
+ dataciteYml = "dataciteYml",
18
19
  }
@@ -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
+ }
@@ -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
- const fetchMock = createFetchMock(vi)
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("rejects data for non-admin context", async () => {
137
- await expect(users(null, {}, nonAdminContext)).rejects.toThrow(
138
- "You must be a site admin to retrieve users",
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 the snapshotDownload cache after exports
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, tag }: { datasetId: string; tag: string },
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
- await downloadCache.drop()
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