Skip to content

Commit beb0df4

Browse files
test(e2e): replace legacy specs with oneshot flow coverage
Co-authored-by: MinecraftFuns <25814618+MinecraftFuns@users.noreply.github.com> Agent-Logs-Url: https://github.com/BTreeMap/OneShot/sessions/9608e356-3f93-462c-8250-5a88f68f2acf
1 parent d2a715c commit beb0df4

9 files changed

Lines changed: 226 additions & 953 deletions

File tree

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,39 @@ jobs:
3737

3838
web-tests:
3939
runs-on: ubuntu-latest
40+
services:
41+
postgres:
42+
image: postgres:16
43+
env:
44+
POSTGRES_USER: oneshot
45+
POSTGRES_PASSWORD: oneshot
46+
POSTGRES_DB: oneshot_test
47+
ports:
48+
- 5432:5432
49+
options: >-
50+
--health-cmd="pg_isready -U oneshot"
51+
--health-interval=5s
52+
--health-timeout=5s
53+
--health-retries=10
54+
env:
55+
H4CKATH0N_DATABASE_URL: postgresql+asyncpg://oneshot:oneshot@localhost:5432/oneshot_test
4056
defaults:
4157
run:
4258
working-directory: web
4359
steps:
4460
- uses: actions/checkout@v4
61+
- uses: astral-sh/setup-uv@v4
4562
- uses: actions/setup-node@v4
4663
with:
4764
node-version: "20"
4865
cache: "npm"
4966
cache-dependency-path: web/package-lock.json
67+
- run: uv sync
68+
working-directory: api
5069
- run: npm ci
5170
- run: npm run test
71+
- run: npx playwright install --with-deps chromium
72+
- run: npx playwright test
5273

5374
container-packaging:
5475
runs-on: ubuntu-latest

api/app/uploads/router.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ class OneShotUploadResponse(BaseModel):
5151
file_id: str
5252

5353

54+
class OneShotTokenStatusResponse(BaseModel):
55+
valid: bool
56+
57+
5458
class OneShotTokenAuditItem(BaseModel):
5559
id: str
5660
target_email: str | None
@@ -234,6 +238,24 @@ async def oneshot_upload(
234238
await file.close()
235239

236240

241+
@router.get("/oneshot/token-status", response_model=OneShotTokenStatusResponse)
242+
async def oneshot_token_status(
243+
authorization: str | None = Header(default=None),
244+
db: AsyncSession = Depends(_db_dep),
245+
) -> OneShotTokenStatusResponse:
246+
token_id = _extract_bearer_token(authorization)
247+
valid = (
248+
await db.execute(
249+
select(func.count(OneShotToken.id)).where(
250+
OneShotToken.id == token_id,
251+
OneShotToken.is_used.is_(False),
252+
OneShotToken.expires_at > func.now(),
253+
)
254+
)
255+
).scalar_one()
256+
return OneShotTokenStatusResponse(valid=bool(valid))
257+
258+
237259
@router.get("/admin/oneshot-tokens", response_model=list[OneShotTokenAuditItem])
238260
async def list_oneshot_tokens(
239261
db: AsyncSession = Depends(_db_dep),

web/e2e/oneshot.spec.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { expect, test } from "@playwright/test";
2+
import { promises as fs } from "node:fs";
3+
4+
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? "admin";
5+
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? "admin";
6+
7+
function toLocalOneShotUrl(issuedLink: string, baseURL: string): string {
8+
const parsed = new URL(issuedLink);
9+
const token = new URLSearchParams(parsed.hash.replace(/^#/, "")).get("token");
10+
if (!token) {
11+
throw new Error(`Missing token in generated link: ${issuedLink}`);
12+
}
13+
return new URL(`/oneshot#token=${token}`, baseURL).toString();
14+
}
15+
16+
async function loginAsAdmin(page: import("@playwright/test").Page): Promise<void> {
17+
await page.goto("/login");
18+
await page.getByTestId("login-username-input").fill(ADMIN_USERNAME);
19+
await page.getByTestId("login-password-input").fill(ADMIN_PASSWORD);
20+
await page.getByTestId("login-password-btn").click();
21+
await expect(page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
22+
await page.goto("/admin");
23+
await expect(page.getByRole("heading", { name: "Admin Panel" })).toBeVisible();
24+
}
25+
26+
async function generateOneShotLink(
27+
page: import("@playwright/test").Page,
28+
baseURL: string,
29+
): Promise<string> {
30+
await page.getByTestId("admin-generate-link").click();
31+
const linkInput = page.getByTestId("admin-generated-link-input");
32+
await expect(linkInput).toHaveValue(/#token=/, { timeout: 10_000 });
33+
const issuedLink = await linkInput.inputValue();
34+
return toLocalOneShotUrl(issuedLink, baseURL);
35+
}
36+
37+
test.describe("OneShot E2E lifecycle", () => {
38+
test("full lifecycle: generate link, external upload, admin audit + download", async ({
39+
page,
40+
browser,
41+
baseURL,
42+
}) => {
43+
test.skip(!baseURL, "Playwright baseURL is required for URL normalization.");
44+
await loginAsAdmin(page);
45+
const oneShotUrl = await generateOneShotLink(page, baseURL!);
46+
47+
const fileName = "oneshot-e2e-upload.txt";
48+
const fileContent = "oneshot e2e dummy payload";
49+
50+
const externalContext = await browser.newContext();
51+
try {
52+
const externalPage = await externalContext.newPage();
53+
await externalPage.goto(oneShotUrl);
54+
await expect(externalPage.getByLabel("Upload File")).toBeVisible();
55+
await externalPage.setInputFiles('input[type="file"]', {
56+
name: fileName,
57+
mimeType: "text/plain",
58+
buffer: Buffer.from(fileContent, "utf8"),
59+
});
60+
await externalPage.getByRole("button", { name: "Upload" }).click();
61+
await expect(externalPage.getByText("Upload complete")).toBeVisible();
62+
} finally {
63+
await externalContext.close();
64+
}
65+
66+
await page.reload();
67+
await page.getByTestId("admin-audit-logs-tab").click();
68+
const fileRow = page.locator("tr", { hasText: fileName }).first();
69+
await expect(fileRow).toBeVisible({ timeout: 20_000 });
70+
71+
const downloadPromise = page.waitForEvent("download");
72+
await fileRow.getByRole("button", { name: "Download" }).click();
73+
const download = await downloadPromise;
74+
expect(download.suggestedFilename()).toBe(fileName);
75+
76+
const outPath = test.info().outputPath(fileName);
77+
await download.saveAs(outPath);
78+
const downloaded = await fs.readFile(outPath, "utf8");
79+
expect(downloaded).toBe(fileContent);
80+
});
81+
82+
test("ephemerality lockout: reused link shows expired state and blocks file selection", async ({
83+
page,
84+
browser,
85+
baseURL,
86+
}) => {
87+
test.skip(!baseURL, "Playwright baseURL is required for URL normalization.");
88+
await loginAsAdmin(page);
89+
const oneShotUrl = await generateOneShotLink(page, baseURL!);
90+
91+
const firstContext = await browser.newContext();
92+
try {
93+
const firstPage = await firstContext.newPage();
94+
await firstPage.goto(oneShotUrl);
95+
await expect(firstPage.getByLabel("Upload File")).toBeVisible();
96+
await firstPage.setInputFiles('input[type="file"]', {
97+
name: "oneshot-first-use.txt",
98+
mimeType: "text/plain",
99+
buffer: Buffer.from("first upload", "utf8"),
100+
});
101+
await firstPage.getByRole("button", { name: "Upload" }).click();
102+
await expect(firstPage.getByText("Upload complete")).toBeVisible();
103+
} finally {
104+
await firstContext.close();
105+
}
106+
107+
const secondContext = await browser.newContext();
108+
try {
109+
const secondPage = await secondContext.newPage();
110+
await secondPage.goto(oneShotUrl);
111+
await expect(secondPage.getByText("Link Expired or Invalid")).toBeVisible({
112+
timeout: 15_000,
113+
});
114+
await expect(secondPage.getByLabel("Upload File")).toBeDisabled();
115+
} finally {
116+
await secondContext.close();
117+
}
118+
});
119+
});

0 commit comments

Comments
 (0)