Skip to content

Commit e7ae8b3

Browse files
committed
feat: add personal access tokens
1 parent 514412f commit e7ae8b3

36 files changed

+2190
-177
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @param {import('knex').Knex} knex
3+
*/
4+
export const up = async (knex) => {
5+
await knex.schema.createTable("user_access_tokens", (table) => {
6+
table.bigIncrements("id").primary();
7+
table.dateTime("createdAt").notNullable();
8+
table.dateTime("updatedAt").notNullable();
9+
table.bigInteger("userId").notNullable().index();
10+
table.foreign("userId").references("users.id");
11+
table.string("name").notNullable();
12+
table.string("token").notNullable().unique();
13+
table.dateTime("expireAt").nullable();
14+
table.dateTime("lastUsedAt").nullable();
15+
table.string("createdBy").notNullable();
16+
});
17+
18+
await knex.schema.createTable("user_access_token_scopes", (table) => {
19+
table.bigIncrements("id").primary();
20+
table.dateTime("createdAt").notNullable();
21+
table.dateTime("updatedAt").notNullable();
22+
table.bigInteger("userAccessTokenId").notNullable().index();
23+
table.foreign("userAccessTokenId").references("user_access_tokens.id");
24+
table.bigInteger("accountId").notNullable().index();
25+
table.foreign("accountId").references("accounts.id");
26+
table.unique(["userAccessTokenId", "accountId"]);
27+
});
28+
};
29+
30+
/**
31+
* @param {import('knex').Knex} knex
32+
*/
33+
export const down = async (knex) => {
34+
await knex.schema.dropTable("user_access_token_scopes");
35+
await knex.schema.dropTable("user_access_tokens");
36+
};

apps/backend/src/api/handlers/getAuthBuildByNumber.e2e.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import request from "supertest";
33
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
44
import z from "zod";
55

6+
import { UserAccessToken, UserAccessTokenScope } from "@/database/models";
7+
import { hashToken } from "@/database/services/crypto";
68
import { factory, setupDatabase } from "@/database/testing";
79

810
import { createTestHandlerApp } from "../test-util";
@@ -137,4 +139,52 @@ describe("getAuthBuildByNumber", () => {
137139
.expect(400);
138140
});
139141
});
142+
143+
describe("with a user access token on explicit project route", () => {
144+
it("returns the build", async () => {
145+
const [user, account] = await Promise.all([
146+
factory.User.create(),
147+
factory.TeamAccount.create({ slug: "acme" }),
148+
]);
149+
150+
const [project] = await Promise.all([
151+
factory.Project.create({
152+
accountId: account.id,
153+
name: "web",
154+
}),
155+
factory.UserAccount.create({ userId: user.id }),
156+
factory.TeamUser.create({
157+
teamId: account.teamId,
158+
userId: user.id,
159+
userLevel: "member",
160+
}),
161+
]);
162+
const bucket = await factory.ScreenshotBucket.create({
163+
projectId: project.id,
164+
});
165+
const build = await factory.Build.create({
166+
projectId: project.id,
167+
compareScreenshotBucketId: bucket.id,
168+
number: 4,
169+
});
170+
171+
const token = UserAccessToken.generateToken();
172+
const userAccessToken = await factory.UserAccessToken.create({
173+
userId: user.id,
174+
token: hashToken(token),
175+
});
176+
await UserAccessTokenScope.query().insert({
177+
userAccessTokenId: userAccessToken.id,
178+
accountId: account.id,
179+
});
180+
181+
await request(app)
182+
.get("/projects/acme/web/builds/4")
183+
.set("Authorization", `Bearer ${token}`)
184+
.expect(200)
185+
.expect((res) => {
186+
expect(res.body.id).toBe(build.id);
187+
});
188+
});
189+
});
140190
});

apps/backend/src/api/handlers/getAuthBuildByNumber.ts

Lines changed: 72 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ import { repoAuth } from "@/web/middlewares/repoAuth";
77

88
import { BuildSchema, serializeBuild } from "../schema/primitives/build";
99
import {
10+
forbidden,
1011
invalidParameters,
1112
notFound,
1213
serverError,
1314
unauthorized,
1415
} from "../schema/util/error";
1516
import { CreateAPIHandler } from "../util";
17+
import {
18+
getAuthorizedProjectFromRequest,
19+
getAuthorizedProjectFromRequestResponses,
20+
} from "./projectAccess";
1621

1722
const BuildNumberSchema = z
1823
.string()
@@ -23,45 +28,85 @@ const BuildNumberSchema = z
2328
id: "BuildNumber",
2429
});
2530

31+
const ProjectPathParamsSchema = z.object({
32+
owner: z.string().min(1),
33+
project: z.string().min(1),
34+
buildNumber: BuildNumberSchema,
35+
});
36+
37+
const responses = {
38+
...getAuthorizedProjectFromRequestResponses,
39+
"200": {
40+
description: "Build",
41+
content: { "application/json": { schema: BuildSchema } },
42+
},
43+
"400": invalidParameters,
44+
"401": unauthorized,
45+
"404": notFound,
46+
"500": serverError,
47+
} satisfies ZodOpenApiOperationObject["responses"];
48+
2649
export const getAuthBuildByNumberOperation = {
2750
operationId: "getAuthBuildByNumber",
28-
requestParams: {
29-
path: z.object({
30-
buildNumber: BuildNumberSchema,
31-
}),
32-
},
33-
responses: {
34-
"200": {
35-
description: "Build",
36-
content: {
37-
"application/json": {
38-
schema: BuildSchema,
39-
},
40-
},
41-
},
42-
"400": invalidParameters,
43-
"401": unauthorized,
44-
"404": notFound,
45-
"500": serverError,
46-
},
51+
requestParams: { path: z.object({ buildNumber: BuildNumberSchema }) },
52+
responses,
4753
} satisfies ZodOpenApiOperationObject;
4854

55+
export const getProjectBuildByNumberOperation = {
56+
operationId: "getProjectBuildByNumber",
57+
requestParams: { path: ProjectPathParamsSchema },
58+
responses: { ...responses, "403": forbidden },
59+
} satisfies ZodOpenApiOperationObject;
60+
61+
async function fetchBuildOrThrow(args: {
62+
projectId: string;
63+
buildNumber: number;
64+
}) {
65+
const { buildNumber, projectId } = args;
66+
67+
const build = await Build.query().findOne({
68+
number: buildNumber,
69+
projectId,
70+
});
71+
72+
if (!build) {
73+
throw boom(404, "Not found");
74+
}
75+
76+
return build;
77+
}
78+
4979
export const getAuthBuildByNumber: CreateAPIHandler = ({ get }) => {
50-
return get("/project/builds/{buildNumber}", repoAuth, async (req, res) => {
80+
get("/project/builds/{buildNumber}", repoAuth, async (req, res) => {
5181
if (!req.authProject) {
5282
throw boom(401, "Unauthorized");
5383
}
54-
const { params } = req.ctx;
5584

56-
const build = await Build.query().findOne({
57-
number: params.buildNumber,
85+
const build = await fetchBuildOrThrow({
5886
projectId: req.authProject.id,
87+
buildNumber: req.ctx.params.buildNumber,
5988
});
6089

61-
if (!build) {
62-
throw boom(404, "Not found");
63-
}
64-
6590
res.send(await serializeBuild(build));
6691
});
92+
93+
get(
94+
"/projects/{owner}/{project}/builds/{buildNumber}",
95+
repoAuth,
96+
async (req, res) => {
97+
const { owner, project, buildNumber } = req.ctx.params;
98+
const authorizedProject = await getAuthorizedProjectFromRequest({
99+
request: req,
100+
owner,
101+
projectName: project,
102+
});
103+
104+
const build = await fetchBuildOrThrow({
105+
projectId: authorizedProject.id,
106+
buildNumber,
107+
});
108+
109+
res.send(await serializeBuild(build));
110+
},
111+
);
67112
};

apps/backend/src/api/handlers/getAuthProject.e2e.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import request from "supertest";
22
import { beforeEach, describe, expect, it } from "vitest";
33

44
import type { Build, Project } from "@/database/models";
5+
import { UserAccessToken, UserAccessTokenScope } from "@/database/models";
6+
import { hashToken } from "@/database/services/crypto";
57
import { factory, setupDatabase } from "@/database/testing";
68

79
import { createTestHandlerApp } from "../test-util";
@@ -53,4 +55,41 @@ describe("getAuthProject", () => {
5355
});
5456
});
5557
});
58+
59+
it("returns a project on explicit route with a user access token", async () => {
60+
const [user, account] = await Promise.all([
61+
factory.User.create(),
62+
factory.TeamAccount.create({ slug: "acme" }),
63+
]);
64+
const [project] = await Promise.all([
65+
factory.Project.create({
66+
accountId: account.id,
67+
name: "web",
68+
}),
69+
factory.UserAccount.create({ userId: user.id }),
70+
factory.TeamUser.create({
71+
teamId: account.teamId,
72+
userId: user.id,
73+
userLevel: "member",
74+
}),
75+
]);
76+
77+
const token = UserAccessToken.generateToken();
78+
const userAccessToken = await factory.UserAccessToken.create({
79+
userId: user.id,
80+
token: hashToken(token),
81+
});
82+
await UserAccessTokenScope.query().insert({
83+
userAccessTokenId: userAccessToken.id,
84+
accountId: account.id,
85+
});
86+
87+
await request(app)
88+
.get("/projects/acme/web")
89+
.set("Authorization", `Bearer ${token}`)
90+
.expect(200)
91+
.expect((res) => {
92+
expect(res.body.id).toBe(project.id);
93+
});
94+
});
5695
});

0 commit comments

Comments
 (0)